Security & JWT
Your SplitEase API works — which means right now, anyone on the internet with curl can read every expense, add fake ones, and rewrite your friends list. Not “a hacker.” Anyone. This module puts a door on the building, and more importantly, makes you understand the lock — because “I added Spring Security and now everything is 403” is the single most common way beginners lose a week.
The Goal
By the end of this module you can:
- Separate authentication from authorization and say which one a 401 vs a 403 is about
- De-magic Spring Security down to what it is: servlet filters running before your controllers ever exist
- Decode a JWT by hand and state precisely what the signature proves — and what it never can
- Explain why stateless tokens beat server-side sessions for an API that wants to scale
- Build register/login endpoints, a JWT validation filter, and a security configuration you understand line by line
- Hash passwords with BCrypt and say why “slow on purpose” is a feature
The Lesson
Two different questions
Security is two questions that beginners blur into one. Keep them apart forever:
| Authentication | Authorization | |
|---|---|---|
| The question | Who are you? | Are you allowed to do this? |
| SplitEase example | Is this request really from Asha? | Can Asha delete Rohit’s expense? |
| Fails with | 401 Unauthorized (misnamed — it means unauthenticated) | 403 Forbidden |
| This module covers | Fully | Just the “must be logged in” rule — per-user ownership comes with groups in module 09 |
Week 6 sketched this at survey level; this is the deep version.
The filter chain, de-magicked
Back in module 03 you traced a request: filter chain → DispatcherServlet → controller → service. You skipped past that first hop. Now it’s the whole story.
A servlet filter is plain, old, pre-Spring Java: a class that sees the raw request before the DispatcherServlet — and therefore before any controller, any @Valid, any code you’ve written in this track. It can inspect the request, modify it, pass it down the chain, or reject it on the spot.
flowchart LR
A[Raw HTTP request] --> B["Security filters - each one job"]
B --> C[DispatcherServlet]
C --> D[Controller]
D --> E[Service]
B -.->|"reject early - 401 goes out, controller never runs"| A
Spring Security is not magic — it is a chain of these filters, each with one job: one reads credentials, one checks the security context, one translates exceptions into 401/403 responses. When you add the starter dependency, that chain gets installed in front of everything. That’s why adding Spring Security with zero config locks your whole API: the chain’s default policy is deny. You’ll add exactly one custom filter to this chain — the one that understands your tokens.
Sessions vs tokens — why APIs pick tokens
How does the server know request number 2 comes from the same person who logged in on request number 1? HTTP itself has no memory. Two answers exist:
| Session-based | Token-based (JWT) | |
|---|---|---|
| After login, server… | stores a session in its memory, gives the browser a cookie with the session id | gives the client a signed token, stores nothing |
| Each request, server… | looks the id up in its session store | verifies the token’s signature with its secret |
| Second server added | must share the session store or pin users to one machine | just works — any server holding the secret can verify |
| Logout / revoke | delete the session — instant | can’t un-issue a token; you wait out the expiry (more below) |
| Natural fit | server-rendered websites | APIs, mobile apps, multiple frontends |
The killer property is stateless: the server remembers nothing between requests, so you can add a tenth server behind a load balancer without sharing session memory — the exact horizontal-scaling move from scaling and distributed systems. The token itself carries the proof; any server with the secret can check it.
JWT anatomy — three parts, one dot between each
A JWT is three base64-encoded chunks joined by dots: header.payload.signature.
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhc2hhQHNwbGl0LmluIiwiZXhwIjoxNzY1NTQ0MDAwfQ.k1XbT0Y3...
| Part | Contains | Example |
|---|---|---|
| Header | The signing algorithm | {"alg":"HS256"} |
| Payload | The claims — who, issued when, expires when | {"sub":"asha@split.in","iat":...,"exp":...} |
| Signature | HMAC of header + payload, computed with the server’s secret | binary, base64-encoded |
Do this once by hand: paste a token into the debugger at jwt.io and watch it decode instantly — no secret needed. That instant decode is the lesson:
- The payload is readable by anyone holding the token. Base64 is encoding, not encryption. Never put a password, an account number, or anything secret in a claim.
- The signature proves two things: the token was issued by someone holding the secret, and not a single character of it has changed since. Change one letter of the payload and the signature no longer matches — the server rejects it.
- The signature does NOT prove the payload is private, that the user still exists, or that the token hasn’t been stolen. A stolen valid token works until it expires. That’s why expiry matters so much.
The lifecycle
sequenceDiagram
participant C as Client
participant A as Auth endpoint
participant F as JWT filter
participant K as Controller
C->>A: POST login with email and password
A->>A: verify password hash and sign a token
A-->>C: token in the response body
C->>C: stores it for later requests
C->>F: GET expenses with header Authorization Bearer token
F->>F: verify signature and expiry
F->>F: populate the SecurityContext with the user
F->>K: request continues to the controller
K-->>C: 200 with the data
The convention on every protected request: Authorization: Bearer <token>. Your filter reads that header, verifies the token, and on success puts an authenticated principal into the SecurityContext — a thread-local holder the rest of the chain consults. If the context is empty when the authorization check runs, the request dies with a 401 before any controller code.
Expiry and refresh — the revocation problem
Here is the ugly truth of stateless: you cannot un-issue a token. The server stores nothing, so there’s no list to delete it from. If a token leaks, it works until it expires. The standard containment:
- Access tokens live ~15 minutes. Short enough that a stolen token is a small window, long enough to not log users out mid-task.
- Refresh tokens live days, are stored server-side (so they can be revoked), and are used only to mint fresh access tokens. Revoking the refresh token caps the damage at one access-token lifetime.
You’ll build access tokens in this module; the refresh flow is a known pattern you can add when SplitEase has real users. What matters now is being able to say why a 15-minute expiry is a security decision, not an annoyance.
Passwords — hash, never encrypt, and slowly
Rule one: you never store the password. Rule two: you never store anything that can be turned back into the password — that rules out encryption, because encrypted data decrypts. You store a hash: a one-way fingerprint. At login you hash the attempt and compare fingerprints.
Why BCrypt and not SHA-256? SHA-256 is built for speed — a GPU computes billions per second, so a leaked table of SHA-hashed passwords falls to brute force in hours. BCrypt is slow on purpose and tunably so (its cost factor doubles the work per increment), and it salts every hash so two users with the same password get different hashes. Slow is the feature: ~100ms per attempt is nothing for one login and ruinous for a billion guesses.
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
The SecurityFilterChain bean, line by line
This bean replaces Spring Security’s deny-everything default with your policy. Every line is a decision — here is each one with its why:
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http,
JwtAuthenticationFilter jwtFilter) throws Exception {
return http
.csrf(csrf -> csrf.disable())
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v1/auth/**", "/actuator/health").permitAll()
.anyRequest().authenticated())
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}
}
| Line | What it decides | Why it’s right here |
|---|---|---|
csrf.disable() | Turn off CSRF protection | CSRF attacks exploit cookies, which browsers attach automatically to requests. Your API uses an Authorization header that JavaScript must attach explicitly — a forged cross-site request can’t add it. No cookie sessions, no CSRF surface. Disabling it for a cookie-based app would be a hole; for a stateless Bearer API it’s correct |
STATELESS | Never create an HTTP session | The token is the state. Letting Spring create sessions anyway would quietly reintroduce the scaling problem tokens exist to solve |
permitAll on /api/v1/auth/** | Login and register are open | Chicken-and-egg: you can’t require a token to obtain a token |
permitAll on /actuator/health | Health stays open | Load balancers and uptime checks don’t log in (module 02’s first endpoint stays reachable) |
anyRequest().authenticated() | Everything else needs auth | Deny-by-default. New endpoints are born protected — forgetting to lock one down is impossible |
addFilterBefore(...) | Your JWT filter runs before the username/password filter slot | It must populate the SecurityContext before the authorization check looks at it |
One more rule: lie a little on login
When login fails, return the same vague message whether the email doesn’t exist or the password is wrong: Invalid credentials. If “no such email” and “wrong password” are distinguishable — by message, status, or even response time — an attacker can harvest your user list by trying emails (user enumeration). Real attackers do exactly this with leaked email dumps. Vague is professional.
See It Move
Step through the full token lifecycle below — then hit the mode toggle to replay the same flow with an expired token and watch where it dies.
Step through it and notice:
- The login request is the only one carrying a password; every request after carries only the token.
- The filter verifies and populates the SecurityContext before the controller exists in the story — exactly the front-of-chain position from module 03’s request journey.
- In expired mode, the request dies at the filter with a 401 — same token, same signature, the clock alone killed it. The controller never knows the request happened.
Check The Concept
How This Shows Up At Work
- The “everything is 403” morning. A teammate adds
spring-boot-starter-securityfor one feature and the whole API locks down, including health checks — deploys start failing. Knowing the default-deny chain and thepermitAllescape hatch turns a panicked morning into a two-line fix. - The security review finding: “Login returns 404 for unknown emails and 401 for wrong passwords — user enumeration.” Now you’ve seen this class of bug before it costs your company a pen-test finding.
- The interview classic: “Why JWT over sessions?” Weak answer: “JWT is more modern.” Your answer: stateless verification, horizontal scaling without a shared session store, and — unprompted — the trade-off: revocation is hard, hence short expiry plus refresh tokens. Naming the downside is what signals seniority.
- The incident at 2 a.m.: mobile users randomly logged out. Root cause: two API servers with different JWT secrets after a config drift — tokens minted by one fail verification on the other. Only someone who knows what the signature check actually does can even form that hypothesis.
Build This
Where you are after module 06: splitease-api has the LedgerService, transactions, and a global error handler returning one ApiError shape — and every endpoint is wide open. This module adds: users, register/login issuing JWTs, a validation filter, and a chain that protects everything except auth and health.
- Add the dependencies to
pom.xml:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.6</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
- Configuration in
application.properties— the secret must be at least 32 bytes for HS256, and a real deployment injects it from the environment, never from git:
splitease.jwt.secret=change-me-in-prod-this-needs-32-bytes-minimum
splitease.jwt.expiry-ms=900000
- The user entity. One catch you now get to learn the cheap way:
useris a reserved word in PostgreSQL, so name the table explicitly:
@Entity
@Table(name = "app_user")
public class UserAccount {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String email;
@Column(nullable = false)
private String passwordHash;
protected UserAccount() {}
public UserAccount(String email, String passwordHash) {
this.email = email;
this.passwordHash = passwordHash;
}
public String getEmail() { return email; }
public String getPasswordHash() { return passwordHash; }
}
Plus the repository:
public interface UserRepository extends JpaRepository<UserAccount, Long> {
Optional<UserAccount> findByEmail(String email);
}
- Auth DTOs — records, as always:
public record RegisterRequest(@Email @NotBlank String email,
@NotBlank @Size(min = 8) String password) {}
public record LoginRequest(@NotBlank String email, @NotBlank String password) {}
public record AuthResponse(String token) {}
JwtService— issue and verify, nothing else:
@Service
public class JwtService {
private final SecretKey key;
private final long expiryMs;
public JwtService(@Value("${splitease.jwt.secret}") String secret,
@Value("${splitease.jwt.expiry-ms}") long expiryMs) {
this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
this.expiryMs = expiryMs;
}
public String issue(String email) {
Instant now = Instant.now();
return Jwts.builder()
.subject(email)
.issuedAt(Date.from(now))
.expiration(Date.from(now.plusMillis(expiryMs)))
.signWith(key)
.compact();
}
public String verifyAndGetSubject(String token) {
return Jwts.parser().verifyWith(key).build()
.parseSignedClaims(token).getPayload().getSubject();
}
}
AuthService— note the deliberately identical failure for both bad email and bad password. AddInvalidCredentialsException extends RuntimeExceptionand map it to 401 in module 06’sGlobalExceptionHandler— the error contract keeps paying off:
@Service
public class AuthService {
private final UserRepository users;
private final PasswordEncoder encoder;
private final JwtService jwt;
public AuthService(UserRepository users, PasswordEncoder encoder, JwtService jwt) {
this.users = users;
this.encoder = encoder;
this.jwt = jwt;
}
@Transactional
public AuthResponse register(RegisterRequest request) {
users.findByEmail(request.email()).ifPresent(u -> {
throw new InvalidExpenseException("Email already registered");
});
users.save(new UserAccount(request.email(), encoder.encode(request.password())));
return new AuthResponse(jwt.issue(request.email()));
}
public AuthResponse login(LoginRequest request) {
UserAccount user = users.findByEmail(request.email())
.orElseThrow(InvalidCredentialsException::new);
if (!encoder.matches(request.password(), user.getPasswordHash())) {
throw new InvalidCredentialsException(); // same message either way
}
return new AuthResponse(jwt.issue(user.getEmail()));
}
}
Then a thin AuthController at /api/v1/auth with POST /register and POST /login, each taking its @Valid record and returning the AuthResponse — by now you can write that controller without a sample.
- The filter — extends
OncePerRequestFilter(runs exactly once per request):
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService;
public JwtAuthenticationFilter(JwtService jwtService) {
this.jwtService = jwtService;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
String header = request.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) {
try {
String email = jwtService.verifyAndGetSubject(header.substring(7));
var auth = new UsernamePasswordAuthenticationToken(email, null, List.of());
SecurityContextHolder.getContext().setAuthentication(auth);
} catch (JwtException e) {
// bad signature or expired — leave the context empty;
// the authorization check downstream turns that into a 401
}
}
chain.doFilter(request, response);
}
}
-
The
SecurityConfigfrom the Lesson, verbatim — plus thepasswordEncoder()bean in the same class. -
Run it and walk the full flow:
./mvnw spring-boot:run
curl http://localhost:8080/api/v1/friends
Expected: 401. Your data just went private. Now the front door:
curl -X POST http://localhost:8080/api/v1/auth/register -H "Content-Type: application/json" -d '{"email":"asha@split.in","password":"goa-trip-2026"}'
curl -X POST http://localhost:8080/api/v1/auth/login -H "Content-Type: application/json" -d '{"email":"asha@split.in","password":"goa-trip-2026"}'
Expected: {"token":"eyJ..."}. Paste that token into jwt.io and read your own claims. Then use it:
curl http://localhost:8080/api/v1/friends -H "Authorization: Bearer <paste-token-here>"
Expected: 200 with your friends list. Same endpoint, three outcomes — no token 401, valid token 200 — and you can explain every hop in between.
-
Break it — tamper with one character. Take your working token, change any single character in its middle (payload) section, and repeat the curl. Expected: 401 — the signature no longer matches the content, and the
JwtExceptionpath in your filter caught it. You just witnessed the integrity guarantee firsthand: one flipped character anywhere kills the token. -
Break it — wait out the clock. Set
splitease.jwt.expiry-ms=60000, restart, log in, confirm a 200 — then wait one minute and repeat the exact same curl. Expected: 401. Same token, same signature, untouched — only theexpclaim’s moment passed. This is precisely the “ expired mode you stepped through above, now happening to your own API. Set expiry back to 900000.
Where to Practice
| Resource | What to do there | How long |
|---|---|---|
| jwt.io | Decode your own SplitEase token; edit the payload in the debugger and watch the signature invalidate | 15 min |
| spring.io/guides | Do the “Securing a Web Application” guide — it’s session-based, which makes a perfect contrast with what you just built | 40 min |
| docs.spring.io | Spring Security reference → Servlet → Architecture — the filter chain diagrams; recognize everything from this module | 30 min |
| Baeldung | Search “Spring Security JWT” — free article; compare their filter with yours line by line | 30 min |
Check Yourself
- Authentication vs authorization — one line each, and which status code belongs to which?
- Where do security filters run relative to the DispatcherServlet, and why did protecting every endpoint require zero controller changes?
- Name the three parts of a JWT. Which part can anyone read, and which part can no one fake without the secret?
- Why 15-minute access tokens? What problem does short expiry bound?
- Why is disabling CSRF acceptable for this API but dangerous for a cookie-based web app?
- Why must login return the same error for a wrong email and a wrong password?
- Why BCrypt over SHA-256, in one sentence built around speed?
- What does your filter put into the
SecurityContextHolder, and which later check consults it?
Answers
- Authentication: who are you? — fails with 401. Authorization: are you allowed to do this? — fails with 403.
- Before — filters see the raw request ahead of the DispatcherServlet, so the controller layer never changes.
anyRequest().authenticated()in the chain protects every route, present and future, in one line. - Header (algorithm), payload (claims), signature. Anyone can read the header and payload — base64 is encoding, not encryption. No one can produce a valid signature without the secret, and changing any character invalidates it.
- Tokens can’t be revoked — the server stores nothing to delete. Short expiry bounds how long a stolen token works; refresh tokens (revocable, stored server-side) keep users logged in across those short windows.
- CSRF rides on credentials the browser attaches automatically — cookies. This API authenticates via an Authorization header that must be attached explicitly by code, which a forged cross-site request cannot do. A cookie-based app disabling CSRF removes a real defense.
- Distinguishable failures let an attacker test which emails have accounts (user enumeration) — turning your login form into a directory of your users for credential-stuffing attacks.
- SHA-256 is built to be fast, and fast is exactly what you don’t want when an attacker is guessing billions of passwords against a leaked table — BCrypt is slow on purpose, tunably so, and salted.
- An authenticated principal (here, the user’s email inside a
UsernamePasswordAuthenticationToken). The authorization filter later in the chain checks it when enforcinganyRequest().authenticated()— empty context means 401.
Explain it out loud: Walk the empty chair through one protected request, from curl -H "Authorization: Bearer ..." to the JSON response: which filter reads the header, what the signature check proves, what lands in the SecurityContext, where a 401 would have been decided, and why the controller needed no changes. Then replay it with an expired token. If the SecurityContext part is fuzzy, rewatch the visualizer.
Still Unclear?
I have a JWT from my own API. Walk me through verifying it by hand: what
exactly gets HMAC-ed, why the server recomputing the signature and comparing
proves integrity, and why the same scheme cannot provide confidentiality.
Use the three base64 chunks of my real token as the worked example. No code.
Play the attacker against my SplitEase API. It uses 15-minute HS256 JWTs,
BCrypt passwords, stateless sessions, CSRF disabled, and vague login errors.
Probe me one attack at a time - stolen token, leaked DB dump, user
enumeration, secret in git, XSS stealing localStorage tokens - and make me
explain which defense applies and where my current design is still weak.
Explain why Spring Security is built as a filter chain instead of, say,
annotations on each controller method. What does the chain position buy in
terms of what code can and cannot be reached by an unauthenticated request?
Connect it to how the DispatcherServlet dispatches to controllers.
Why AI Can’t Do This For You
AI will generate this entire security setup — filter, config, JwtService — flawlessly in one prompt. Then production greets you with a 401 that only happens on the second server, or only after exactly fifteen minutes, or only for users who registered before Tuesday. The error message is identical in every case: 401. The diagnosis lives in your config drift, your clock skew, your secret management — places no prompt can see, and exactly the places this module taught you to look.
Worse, security is the one domain where pasted code that runs can still be catastrophically wrong — a permitAll that’s too wide, a CSRF disable in a cookie app, a password logged in plaintext. The code looks identical either way. Only someone who knows what each line defends against can tell the secure version from the breach waiting to happen. That review — of AI’s output, your teammate’s PR, your own old code — is the job they pay backend engineers for.
Module done? Add it to today’s tracker