RizTech Academy logo
RizTech Academy
SecurityLesson 5 of 525 min

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 Authorization header) 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 User entity ships its passwordHash, 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's ProblemDetail with 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.properties in 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-all anyRequest().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. Prefer anyRequest().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 is permitAll(), 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=admin or {"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 Entity lets a client set fields like role or status they 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 accidental permitAll.
  • 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

  1. Take a csrf().disable() and decide, for a token API vs a session app, whether it is correct; fix the wrong case.
  2. Return an entity with a sensitive field from an endpoint and confirm it leaks; replace with a DTO.
  3. Trigger an unhandled exception with DEBUG detail reaching the client; add a handler returning a generic ProblemDetail and confirm no internals leak.
  4. Build an IDOR: a GET /api/parcels/{id} protected only by hasRole and fetch another user's parcel; fix it by scoping the query to the caller and returning 404, and write the test that proves it.
  5. Set a default of permitAll() and add a new endpoint; observe it is exposed. Switch the default to authenticated() and confirm the new endpoint is now protected — fail closed.
  6. Authorise an action using a userId from the request body, show it can be forged, then rewrite it to use authentication.name.

Official documentation

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