Security mistakes that reach production
This lesson closes the security module with the mistakes that actually reach production — the ones code reviews catch and, when they do not, incident reports do. Each is common, each is avoidable, and knowing them is what turns "I added Spring Security" into "I secured this API". None is exotic; they are the predictable ways Spring APIs get exposed.
Disabling CSRF without understanding it
The most common tutorial-driven mistake: csrf(c -> c.disable()) copied blindly. The rule, precisely:
- A stateless, token-authenticated API (JWT in the
Authorizationheader) is not vulnerable to CSRF in the classic sense — there is no ambient cookie for a malicious site to ride — so disabling CSRF is appropriate there. - A session-cookie web app is vulnerable — CSRF protection stops another site forging a state-changing request using the user's session cookie — so CSRF must stay on.
The mistake is disabling CSRF on a cookie/session app because a JWT tutorial did. Know which you are building. Disable CSRF only when you have reasoned that your auth is stateless and cookie-free.
Leaking sensitive data in responses and errors
Two leaks that ship constantly:
- Returning entities with sensitive fields. The DTO lesson's warning is a security issue: serialising a
Userentity ships itspasswordHash, internal flags, everything. Always map to DTOs, exposing only intended fields — never the entity. - Verbose errors in production. A stack trace or an exception message returned to the client leaks your
class names, library versions, SQL, and internal structure — a map for an attacker. In production,
DEBUG-style detail must go to logs, and the client gets a generic, safe message (the error-handling lesson'sProblemDetailwith no internals). Never let a raw exception or stack trace reach the client.
Weak or mishandled passwords and secrets
- Passwords: the whole password-storage lesson — never plaintext, never a fast hash, always BCrypt via
PasswordEncoder. A surprising number of breaches are plaintext or MD5 password tables. - Secrets in code or config. The JWT signing secret, the database password, API keys — committing any of
these to the repository is a breach the moment it is pushed (and it lives in git history forever). They
come from environment variables / a secrets manager, never
application.propertiesin the repo. A weak or hard-coded JWT secret is especially dangerous: anyone who has it can forge valid tokens for any user.
Broken object-level authorisation (IDOR)
The most damaging logic bug in APIs, and the one role checks do not catch: Insecure Direct Object
Reference — a user accessing another user's data by changing an id. GET /api/parcels/42 with only a
hasRole("PARTNER") check lets any partner fetch any parcel, including id 42 that belongs to a different
partner. The role check passed ("is a partner") but the object rule was never enforced ("is your parcel").
The fix, from the authorisation lesson: scope the query to the caller (findByOwnerUsername(currentUser))
so forbidden rows are never returned, and for single-object fetches fold ownership into the query, returning
404 for records the caller should not know exist. Testing this — "user A cannot fetch user B's record" —
is exactly the test that catches IDOR before production. Never assume a role check is object-level security;
it is not.
Over-permissive rules and forgotten endpoints
permitAll()too broadly, or a catch-allanyRequest().permitAll()that accidentally exposes endpoints you meant to protect. Because rules match top-to-bottom, a permissive rule placed too early opens everything below it. PreferanyRequest().authenticated()as the default and open specific public paths — fail closed, not open.- New endpoints added without a matching rule — if your default is
authenticated(), a forgotten endpoint is at least protected; if your default ispermitAll(), it is exposed. This is why "secure by default" (the basics lesson) matters: make the default deny, so forgetting a rule fails safe.
Trusting client input for authorisation
- Never authorise based on a value the client sent. A request body or query param saying
?role=adminor{"userId": 7}is client-controlled and can be anything. Authorisation must use the authenticated principal (authentication.name, the roles Spring loaded), never a value from the request. "Fetch the parcels for the userId in the request body" is an IDOR waiting to happen; "fetch the parcels for the authenticated user" is correct. - Mass assignment (the DTO lesson) is the input-trust mistake for writes:
@RequestBody Entitylets a client set fields likeroleorstatusthey should not control. Use request DTOs with only client-settable fields.
The pre-production security checklist
Before shipping a Spring API, confirm:
- CSRF handled correctly for your auth model (off only for stateless token APIs).
- Entities never exposed — DTOs only; no sensitive fields leaked.
- No stack traces or internal errors returned to clients (generic message; details to logs).
- Passwords BCrypt-hashed; secrets (JWT key, DB password) from the environment, never committed.
- Object-level authorisation enforced (queries scoped to the caller) — not just role checks; IDOR tested.
- Default is
authenticated(); public endpoints opened explicitly; no accidentalpermitAll. - Authorisation uses the authenticated principal, never client-supplied ids/roles.
- HTTPS in production (the deployment module) so tokens and credentials are not sent in the clear.
Every item here traces to a mistake that has caused real breaches. Running this checklist — and testing the denial paths, not just the happy paths — is the difference between security that looks present and security that holds.
Check your work
CSRF. Disable only for stateless token APIs (no cookie to ride); keep it on for session-cookie apps. Do not disable by reflex.
Data leaks. Never expose entities (DTOs only — sensitive fields leak otherwise); never return stack traces/internal errors to clients (generic message, details to logs).
Passwords and secrets. BCrypt for passwords; JWT secret / DB password / keys from the environment, never committed (a leaked JWT secret lets anyone forge tokens).
IDOR / object-level. Role checks do not enforce "your own record" — scope queries to the caller, 404 for unknown-to-you records; test that user A cannot read user B's data. The most damaging logic bug.
Fail closed. Default authenticated(), open public paths explicitly; a forgotten endpoint is then
protected, not exposed. Rules match top-to-bottom — no permissive rule too early.
Trust the principal, not the client. Authorise on the authenticated principal/roles, never a client-supplied id or role; use request DTOs to prevent mass assignment.
Practice
- Take a
csrf().disable()and decide, for a token API vs a session app, whether it is correct; fix the wrong case. - Return an entity with a sensitive field from an endpoint and confirm it leaks; replace with a DTO.
- Trigger an unhandled exception with
DEBUGdetail reaching the client; add a handler returning a genericProblemDetailand confirm no internals leak. - Build an IDOR: a
GET /api/parcels/{id}protected only byhasRoleand fetch another user's parcel; fix it by scoping the query to the caller and returning 404, and write the test that proves it. - Set a default of
permitAll()and add a new endpoint; observe it is exposed. Switch the default toauthenticated()and confirm the new endpoint is now protected — fail closed. - Authorise an action using a
userIdfrom the request body, show it can be forged, then rewrite it to useauthentication.name.
Official documentation
- OWASP — API Security Top 10 — Including broken object-level authorisation (IDOR).
- Spring Security — CSRF — When to keep it and when to disable it.
- OWASP — Cheat Sheet Series — Authoritative guidance on each mistake here.
Next: transaction propagation and isolation, in depth.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship