Two questions, and beginners merge them into one:
- Authentication — who are you?
- Authorization — are you allowed to do that?
They fail differently and they are checked in different places. Confusing them is how a system ends up correctly identifying a user and then letting them read somebody else's order.
Storing passwords
Assume your database will leak. Design for the day it does.
Never store a password. Store a slow hash of it — bcrypt, scrypt or Argon2 — and let the library handle the salt.
/**
* BCrypt deliberately makes hashing slow, which is the point: it makes brute-forcing a
* stolen password table expensive. Never store a password with a fast hash such as MD5
* or SHA-256.
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}Read that comment carefully, because it is the counter-intuitive bit. MD5 and SHA-256 are not "weak" hashes in the usual sense — they are fast, and fast is the flaw. A GPU tries billions of fast hashes per second against a leaked table. Bcrypt is deliberately engineered to take milliseconds, which is invisible on one login and ruinous across a billion guesses.
The salt is random per password, stored alongside the hash, and it is what stops one precomputed table from cracking every account at once. Bcrypt does this for you — which is the real argument for using the library rather than assembling this yourself.
Sessions or tokens?
| Session cookie | Token (JWT) | |
|---|---|---|
| Where state lives | On the server | In the token itself |
| Scaling across servers | Needs shared session storage | Nothing to share |
| Revoking access | Delete the session — instant | Cannot, until it expires |
| Works well for | Server-rendered web apps | APIs with several clients |
| Main risk | CSRF | Theft — anyone holding it is you |
Neither is "better". The demo app is an API called by a React app, an Angular app and Swagger, so it is stateless and token-based. A server-rendered site with one frontend should use sessions and save itself the trouble.
What a JWT actually is
Three base64 chunks separated by dots: a header, a payload of claims, and a signature. The signature proves we issued it and nobody edited it.
public String generateToken(User user) {
Instant now = Instant.now();
Instant expiry = now.plusSeconds(properties.jwt().expirationMinutes() * 60);
return Jwts.builder()
.subject(user.getEmail())
.claim(CLAIM_ROLE, user.getRole().name())
.claim(CLAIM_USER_ID, user.getId())
.issuedAt(Date.from(now))
.expiration(Date.from(expiry))
.signWith(key)
.compact();
}Three consequences people get wrong:
- Signed is not encrypted. Anyone holding the token can read every claim — paste one into jwt.io and look. Never put anything secret in there.
- You cannot revoke it. There is no server-side record to delete, so a stolen token works until it expires. That is the price of statelessness. Mitigate with short lifetimes plus a refresh token; do not pretend the problem is not there.
- Verify the signature server-side, every request. An unverified token is just a string the caller wrote, and "decode the claims and trust them" is a real vulnerability that ships regularly.
And validate the key at startup rather than on first use:
@PostConstruct
void init() {
byte[] keyBytes = properties.jwt().secret().getBytes(StandardCharsets.UTF_8);
// HS256 requires at least 256 bits. Failing loudly at startup beats discovering a weak
// key in production.
if (keyBytes.length < 32) {
throw new IllegalStateException(
"pizza.jwt.secret must be at least 32 characters for HS256 (got " + keyBytes.length + ")");
}
this.key = Keys.hmacShaKeyFor(keyBytes);
}The rules run in order, and first match wins
Every request passes through a chain of filters before it reaches your code. Somewhere in that chain is a list of rules mapping paths to requirements — and the order of that list is security-critical.
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/register", "/api/auth/login")
.permitAll()
// ---- public menu ---------------------------------------------------
.requestMatchers(HttpMethod.GET, "/api/products/**", "/api/toppings/**", "/api/crusts/**")
.permitAll()
// ---- orders --------------------------------------------------------
// Specific first: order history needs a real account.
.requestMatchers(HttpMethod.GET, "/api/orders/mine")
.authenticated()
// Placing an order must NOT require a login — this is guest checkout.
.requestMatchers(HttpMethod.POST, "/api/orders")
.permitAll()
// ---- admin ---------------------------------------------------------
.requestMatchers("/api/admin/**")
.hasRole("ADMIN")
// Default deny. Anything not listed above needs authentication, so a new
// endpoint is closed until someone deliberately opens it.
.anyRequest()
.authenticated())Two things to take from this.
Specific before general. If a broad permitAll() on
/api/orders/** came first, the /api/orders/mine rule below it would never
be evaluated and every customer's order history would be public. Nothing warns you. The endpoint
just works, for everyone.
End with default deny. anyRequest().authenticated() means the next
endpoint someone adds is closed until they deliberately open it. The alternative — default allow —
means the next endpoint someone adds is public until they remember to protect it, and one day they
will not.
Note also the comments marking deliberate trade-offs: reading one order is public so a guest can see their confirmation page, and the code says so, along with the limitation that follows from it. An intentional hole that is written down is a decision; the same hole undocumented is a bug waiting to be discovered by someone else.
Authorization is not just about the endpoint
This is the most commonly shipped access-control bug, and it passes every test that only checks "is the user logged in".
GET /api/orders/42 — the caller is authenticated, so the endpoint rule allows it.
But is order 42 theirs? If you do not check ownership, any logged-in user can read every
order by changing the number. It is called insecure direct object reference, and the
fix is a rule: never look something up by an id from the request without checking that this
caller may see it.
Two structural defences are visible in the demo app. Endpoints that mean "the signed-in caller"
carry no id at all — /api/me/** and /api/orders/mine derive the user from
the token, so there is nothing in the path to swap. And public identifiers are UUIDs rather than
sequential integers, so they cannot be walked. The second is a mitigation, not a control:
unguessable is not the same as protected.
Defence in depth: guard the method too
URL rules protect one entry point. A service is a bean that any future controller, scheduled job or message listener can inject — so put the check on the method as well:
/**
* Every method here carries @PreAuthorize("hasRole('ADMIN')") even though SecurityConfig
* already restricts /api/admin/** to admins. That is not belt-and-braces for its own sake:
* URL rules protect one entry point, and this service is a bean that any future controller,
* scheduled job or message listener can inject. The annotation travels with the method, so
* the guard cannot be left behind when the caller changes.
*
* ⚠️ Being proxy-based, it has the same blind spot as @Transactional and @Cacheable:
* a call from another method inside THIS class skips the check entirely.
*/
@Override
@PreAuthorize("hasRole('ADMIN')")
@Transactional(readOnly = true)
public List<AdminUserDTO> getAllUsers() {
return userDAO.findAllForAdmin();
}That warning is the proxy rule again, and here it is a security hole rather than a performance one.
Do not leak information in your errors
@ExceptionHandler(AuthenticationException.class)
public ResponseEntity<ApiError> handleAuthentication(AuthenticationException ex, HttpServletRequest request) {
// Deliberately vague: saying "no such user" vs "wrong password" tells an attacker which
// email addresses are registered.
ApiError error = new ApiError(HttpStatus.UNAUTHORIZED, "Invalid email or password", request.getRequestURI());
return ResponseEntity.status(error.getStatus()).body(error);
}"No account with that email" is a free account-enumeration API. The same reasoning applies to
token parsing: the demo app's JwtService.parse returns null for malformed, forged
and expired tokens alike, because distinguishing them only helps someone probing the
implementation.
Logging in with Google — OAuth2 in one paragraph
OAuth2 login means you never see the user's Google password. You redirect them to Google, Google authenticates them and redirects back with a code, your server exchanges that code for the user's profile, and you then issue your own token as usual. The provider handles authentication; authorization is still entirely yours. Worked example in Spring Boot – OAuth2.
Secrets
Never in git. Not in a properties file you commit, not in a comment, not "temporarily". Git remembers, and rotating a leaked key is far more work than never leaking it.
# Stripe keys are NEVER committed. Put real test keys in
# application-local.properties (gitignored) or set the env vars below.
pizza.stripe.secret-key=${STRIPE_SECRET_KEY:}
pizza.stripe.publishable-key=${STRIPE_PUBLISHABLE_KEY:}
pizza.stripe.webhook-secret=${STRIPE_WEBHOOK_SECRET:}Environment variables locally; a secret manager in production — AWS Secrets Manager, Vault or your platform's equivalent. And if a key does reach a repository, rotate it. Deleting the commit is not enough.
The short list worth knowing by name
| Attack | What it is | Defence |
|---|---|---|
| SQL injection | Input that changes the query's structure | Bind parameters. Never concatenate. |
| Broken access control | Reading someone else's record by changing an id | Check ownership on every lookup |
| XSS | Attacker's script runs in another user's browser | Escape on output; treat stored text as data |
| CSRF | Another site makes the browser send an authenticated request | CSRF tokens — a cookie-auth problem, not a bearer-token one |
| Mass assignment | Caller sets a field you never exposed, e.g. their role | Separate request and response DTOs |
| Trusting client data | Price or permission arrives in the request body | Server decides; the client only chooses |
One note on that CSRF row, because disabling CSRF protection is widely copy-pasted without the reasoning:
// Safe to disable ONLY because this API is stateless and token-based: there is no
// session cookie for a cross-site request to ride on. Never do this for a
// cookie-authenticated app.
.csrf(csrf -> csrf.disable())What to remember
- Authentication is who; authorization is what you may do. Different questions, different places.
- Slow hash for passwords — bcrypt, scrypt, Argon2. Fast hashes are the flaw.
- Sessions for server-rendered apps, tokens for APIs. A JWT is readable and cannot be revoked.
- Rules are ordered and first match wins. Specific before general, and end with default deny.
- Never look something up by a request id without checking ownership.
- Guard the service method too — URL rules protect one entry point.
- Vague auth errors. Detailed ones enumerate your users.
- Secrets from the environment. If one reaches git, rotate it.
Next: caching, async work and messaging — three ways to stop making the caller wait.