CicloUrbana already knows who each citizen of Ribalta is, but it asks in a way that does not stand up to real use. With HTTP Basic, the mobile application has to store Marta's password and send it on every request: when opening the map, when looking up a station, when starting a rental. The user's most valuable credential travels the network dozens of times a day, the server computes a ninety-millisecond BCrypt on every one of them, and there is no way to expire access without forcing a password change.
This lesson solves it with the standard pattern of modern APIs: the password is sent just once, and in exchange the server issues a signed, expirable token that the client then presents in the Authorization: Bearer header. We will look at exactly what a JWT is field by field, we will implement it with JJWT and a filter of our own, we will add refresh tokens with rotation and database revocation, and we will face honestly the two uncomfortable questions of the stateless model: where the client stores the token and what signing out really means.
Warning. Every secret, key and token in this lesson is fictional and truncated; none of them should be reused. The signing secret is never written into the repository: it is injected through an environment variable or a secrets manager. And a home-grown JWT implementation, even one using a solid library, must be reviewed by a security professional before being exposed to the internet; section 15 explains the alternative that avoids writing this code at all.
Contents
- Why a mobile app needs stateless authentication
- Cookie session versus token: the honest comparison
- Anatomy of a JWT
- The claims: standard ones and CicloUrbana's own
- Signing algorithms: HS256 versus RS256
- Signed is not encrypted
- JJWT and
JwtProperties JwtService: generate, read and validateJwtAuthenticationFilter- Registering the filter in the security chain
POST /api/v1/auth/login- Refresh tokens: rotation and revocation
- Signing out in a stateless world
- Where the client stores the token
- Documenting the scheme in OpenAPI and the production alternative
- Testing the complete flow with curl
- Common Mistakes and Tips
- Exercises
- Why a mobile app needs stateless authentication
The classic alternative to the token is the server-side session: the user signs in once, the server keeps their state in memory and hands them a cookie with an identifier. It works very well for a traditional website and very badly for CicloUrbana:
- The client is not a browser. A mobile application does not handle cookies naturally, and browsers' third-party policies make the web scenario more complicated all the time.
- The session lives on one instance. With three replicas behind a load balancer, Marta's session is on number 2 and the other two know nothing about it: the ways out are sticky sessions (fragile) or replication (expensive). With a token, any instance serves any request.
- It contradicts the REST constraint from 03-01, which demands that every request contain all the information needed, and it consumes memory in proportion to the number of connected users.
The stateless solution inverts the storage: the state travels with the client, signed by the server so that it cannot be tampered with. The server remembers nothing; it only verifies a signature.
- Cookie session versus token: the honest comparison
| Cookie session | JWT token | |
|---|---|---|
| Where the state lives | On the server | On the client |
| Horizontal scaling | Requires sticky sessions or a shared store | Trivial |
| Immediate revocation | Yes: delete the session | No: valid until it expires |
| Size per request | ~50 bytes | 300–1000 bytes |
| Role change / mobile client | Immediate / awkward | Until expiry / natural |
| CSRF | Vulnerable, requires an anti-CSRF token | Does not apply if it travels in a header |
| XSS | The HttpOnly cookie is not readable by JS |
Serious if stored in localStorage |
The JWT's three drawbacks must be stated without embellishment, because they are almost never mentioned:
It cannot be revoked easily. A stolen token is valid until it expires and there is nothing on the server that invalidates it: if Marta loses her phone, her token keeps working. The main defence is a short expiry, and that is why section 12 introduces refresh tokens. It takes up space: a JWT with roles is around 400 bytes travelling on every request, a real cost over a mobile connection. And it is dangerous if stored badly: keeping it in localStorage makes it readable by any JavaScript on the page, so an XSS goes from being an annoying problem to a complete identity theft (section 14).
A JWT is not always the best option. For a classic web application with server-side views, the cookie session is still simpler and safer. CicloUrbana chooses the token because its client is mobile and its API is stateless, not because it is modern.
- Anatomy of a JWT
A JSON Web Token (RFC 7519) is three blocks encoded in Base64URL and separated by dots:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJtYXJ0YUByaWJhbHRhLmV4YW1w bGUiLCJ1c2VySWQiOjQyLCJpc3MiOiJjaWNsb3VyYmFuYSJ9.K3Bn1sV7oQ2xJ0dLpR8mFq4tYc9Z └──────── header ────────┘ └──────────── payload ────────────┘ └ signature ┘
Decoding the first two blocks —which is trivial: echo '<block>' | base64 -d— yields JSON:
{ "iss": "ciclourbana",
"sub": "[email protected]",
"userId": 42,
"roles": ["ROLE_CITIZEN"],
"iat": 1773561600,
"exp": 1773562500,
"jti": "8f2a4c31-5b7e-4d19-9c02-6ea3f1b0d47c" }And the third block, the signature, is computed like this:
Base64URL is not encryption: it is a reversible encoding that exists only so that the token can travel without problematic characters in a URL or a header. Anyone can read the payload; what nobody can do without the secret is produce a valid signature. Verification works like this:
flowchart LR
S["Split<br/>header . payload . signature"] --> R["Recompute the HMAC<br/>with the server's secret"]
R --> C{"Does the<br/>signature match?"}
C -- "No" --> X["Tampered or forged → 401"]
C -- "Yes" --> E{"Is exp in<br/>the future?"}
E -- "No" --> Y["Expired → 401"]
E -- "Yes" --> OK["Authenticate"]
If an attacker changes "roles":["ROLE_CITIZEN"] to "roles":["ROLE_ADMIN"], the payload changes, the recomputed HMAC no longer matches the signature that accompanies the token and the server rejects it. They cannot recompute the correct signature because they do not have the secret.
- The claims: standard ones and CicloUrbana's own
Each field of the payload is a claim, an assertion about the subject. The RFC defines seven registered ones:
| Claim | Name | What it is for | In CicloUrbana |
|---|---|---|---|
iss |
Issuer | Who issued the token | ciclourbana |
sub |
Subject | Who it is about | The user's email |
aud |
Audience | Who it is valid for | ciclourbana-api |
exp |
Expiration | The instant after which it is worthless | Issue + 15 minutes |
nbf / iat |
Not before / Issued at | From when it is valid, when it was issued | iat only |
jti |
JWT ID | Unique identifier of the token | UUID, for revocation |
The first three matter more than they seem to. Validating iss and aud prevents a token issued by another system —or for another service in the same organisation— from being accepted here, a real and frequent failure in architectures with several services; and jti is what makes a revocation list possible (section 13). To these CicloUrbana adds two claims of its own:
| Own claim | Type | Why |
|---|---|---|
roles |
List of strings | Avoids querying the database on every request to know what the user may do |
userId |
Number | The same reason AuthenticatedUser keeps the id (05-03): it allows ownership of a rental to be checked without a query |
Every claim you add is a commitment. It enlarges the token and, above all, freezes a piece of data: if an administrator removes the OPERATOR role from Luis, his token keeps saying he has it until it expires. With a fifteen-minute lifetime that is acceptable; with twenty-four hours it is not. It is the limitation we anticipated in exercise 3 of 05-03, and the underlying reason why the access token must be short.
- Signing algorithms: HS256 versus RS256
| HS256 (HMAC-SHA256) | RS256 (RSA-SHA256) | |
|---|---|---|
| Type | Symmetric: one shared secret | Asymmetric: a key pair |
| Who signs / verifies | The same secret for both | The private key signs, the public one verifies |
| Signature size and speed | 32 bytes, very fast | 256 bytes, slow to sign |
| Main risk | The secret is held by everybody who verifies | Managing the key pair |
| When to use it | A single service issues and verifies | Several services verify; external provider |
CicloUrbana uses HS256, because today the same application issues and verifies the tokens and a shared secret is the simplest and perfectly secure solution in that scenario. You must switch to RS256 as soon as more than one service needs to verify: with HS256, whoever can verify can also issue, so sharing the secret with five microservices means five teams can manufacture administrator tokens. With RS256 the services receive only the public key and verify without being able to forge. That is the module 7 scenario, and the reason identity providers always use asymmetric algorithms.
The
alg: nonevulnerability. Early JWT implementations accepted a token whose header declared"alg":"none"and took it as valid with no signature. An attacker only had to strip the signature and change the header. Another variant consisted of changingRS256toHS256so that the server used the public key as the HMAC secret —a key the attacker knows. The defence is the same in both cases: the expected algorithm is decided by the server, never by the token. JJWT 0.12 does this correctly if you useverifyWith(key), which pins the algorithm from the key type. Never write code that readsalgfrom the header to decide how to verify.
- Signed is not encrypted
Highlighted warning. A JWT's payload is readable by anyone who has the token: all it takes is decoding Base64URL, with no secrets and no tools. The signature guarantees integrity and authenticity, not confidentiality.
What must never go into a JWT:
- Passwords or password hashes.
- National ID numbers, postal addresses, phone numbers, health data or any sensitive personal data.
- Card numbers, banking details, API keys, internal secrets or infrastructure paths.
- Confidential commercial information, such as the accumulated amount of the rentals.
What is reasonable: the user's identifier, their email if the service treats it as a public identifier, their roles and the timestamps. The practical rule: if you would not write it on a postcard, do not put it in a JWT. When confidentiality really is needed there is JWE (JSON Web Encryption), which encrypts the payload; it is markedly more complex, and the usual solution is simpler: do not put the data in the token and look it up on the server when it is needed.
- JJWT and
JwtProperties
JwtProperties<!-- All three with <version>0.12.6</version>: JJWT is not managed by the starter-parent -->
<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>All three are necessary and the split is deliberate: jjwt-api holds the interfaces and is the only one you compile against; jjwt-impl and jjwt-jackson are implementations in runtime scope, which prevents your code from coupling to internal details. If a ClassNotFoundException about DefaultJwtBuilder shows up, jjwt-impl is missing. And beware: JJWT is not managed by the spring-boot-starter-parent, so the version has to be pinned by hand and reviewed periodically (05-05).
The configuration, with typed @ConfigurationProperties as in 02-05:
# application.yml
ciclourbana:
jwt:
issuer: ciclourbana
audience: ciclourbana-api
expiration: 15m # access token: short on purpose
refresh-expiration: 30d
secret: ${JWT_SECRET} # NEVER a literal value herepackage com.ciclourbana.security;
@ConfigurationProperties(prefix = "ciclourbana.jwt")
@Validated
public record JwtProperties(
@NotBlank @Size(min = 43) String secret, // 43 Base64 characters = 256 bits
@NotBlank String issuer,
@NotBlank String audience,
@NotNull Duration expiration,
@NotNull Duration refreshExpiration) {
public JwtProperties {
if (expiration.compareTo(Duration.ofHours(1)) > 0) {
throw new IllegalArgumentException(
"The access token must not last more than an hour; use refresh");
}
}
}Four important decisions:
The secret comes from an environment variable. ${JWT_SECRET} with no default value: if the variable does not exist, the application does not start, which is exactly what we want. Writing a default secret into the YAML is worse than having no security, because it creates a false sense of having some: it ends up in Git, in the Docker image and on every team member's laptop, and that same value ends up in production with alarming frequency.
@Size(min = 43) is not arbitrary. HS256 requires a key of at least 256 bits, which in Base64 is 43 characters; JJWT rejects shorter keys with WeakKeyException, and validating it at startup turns that runtime failure into a comprehensible configuration error. A suitable secret is generated, never invented:
openssl rand -base64 48 # 64 Base64 characters = 384 bits
export JWT_SECRET='...' # the real value, never in a file in the repositoryThe compact constructor validates the policy, not only the format: somebody configuring expiration: 24h is not a type error but it is a security one, and startup prevents it. And the secret is rotated, like any credential: in production it lives in a secrets manager (Vault, AWS Secrets Manager, Kubernetes secrets) and is changed periodically, in the knowledge that rotating it invalidates every token in circulation.
JwtService: generate, read and validate
JwtService: generate, read and validatepackage com.ciclourbana.security;
@Service
public class JwtService {
private static final Logger log = LoggerFactory.getLogger(JwtService.class);
private final JwtProperties properties;
private final SecretKey key;
private final Clock clock; // the Clock bean from 03-03
public JwtService(JwtProperties properties, Clock clock) {
this.properties = properties;
this.clock = clock;
// Decodes the Base64 secret and checks that it is strong enough
this.key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(properties.secret()));
}
/** Issues an access token for an already authenticated user. */
public String generateToken(AuthenticatedUser user) {
Instant now = Instant.now(clock);
return Jwts.builder()
.issuer(properties.issuer())
.audience().add(properties.audience()).and()
.subject(user.getUsername())
.id(UUID.randomUUID().toString()).issuedAt(Date.from(now)) // jti, iat
.expiration(Date.from(now.plus(properties.expiration())))
.claim("userId", user.getUserId())
.claim("roles", user.getAuthorities().stream()
.map(GrantedAuthority::getAuthority).toList())
.signWith(key, Jwts.SIG.HS256)
.compact();
}
/** Verifies signature, issuer, audience and expiry. Throws if anything fails. */
public Claims extractClaims(String token) {
return Jwts.parser()
.verifyWith(key) // pins the algorithm: does not read it from the token
.requireIssuer(properties.issuer())
.requireAudience(properties.audience())
.clockSkewSeconds(30) // clock tolerance between machines
.build().parseSignedClaims(token).getPayload();
}
/** Non-throwing version: useful in the filter, where an invalid token is routine. */
public Optional<Claims> validate(String token) {
try {
return Optional.of(extractClaims(token));
} catch (ExpiredJwtException e) {
log.debug("Expired token"); // common: NOT an error
} catch (SecurityException | MalformedJwtException e) {
log.warn("Token with an invalid signature or malformed"); // this one is suspicious
} catch (JwtException | IllegalArgumentException e) {
log.warn("Invalid token: {}", e.getClass().getSimpleName());
}
return Optional.empty(); // the token is never written to the log
}
}Five details that make this code correct and not merely functional:
verifyWith(key)pins the algorithm from the key type. That is what closes the door onalg: noneand on the RS256/HS256 confusion from section 5.requireIssuerandrequireAudiencereject tokens from elsewhere. They are almost always forgotten.clockSkewSeconds(30)tolerates clock drift between machines. Without it, two servers two seconds apart produce intermittent rejections that are impossible to reproduce. And the injectedClock—the bean from 03-03— will make it possible to test expiry in module 6 without waiting fifteen minutes.- An expired token is logged at
DEBUG; an invalid signature, atWARN. The first is normal operation; the second may be an attack, and mixing them makes the log useless (09-05). - The token is never logged: a token in a log is a credential in a log. And
.audience().add(...).and()is the JJWT 0.12 fluent API, which changed with respect to 0.11, so a lot of code from the internet will not compile against this version.
JwtAuthenticationFilter
JwtAuthenticationFilterThe filter is the piece that turns an HTTP header into an authenticated user. It extends OncePerRequestFilter, just like the TraceFilter from 03-06, to guarantee a single execution per request even when there are internal forwards.
package com.ciclourbana.security;
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private static final String HEADER = "Authorization";
private static final String PREFIX = "Bearer ";
private final JwtService jwtService; // constructor omitted
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
extractToken(request).flatMap(jwtService::validate)
.ifPresent(claims -> authenticate(claims, request));
// ALWAYS carry on: rejecting is the AuthorizationFilter's job
chain.doFilter(request, response);
}
private Optional<String> extractToken(HttpServletRequest request) {
String header = request.getHeader(HEADER);
return (header != null && header.startsWith(PREFIX))
? Optional.of(header.substring(PREFIX.length()).trim()) : Optional.empty();
}
@SuppressWarnings("unchecked")
private void authenticate(Claims claims, HttpServletRequest request) {
if (SecurityContextHolder.getContext().getAuthentication() != null) return;
List<String> roles = claims.get("roles", List.class);
var authorities = roles.stream().map(SimpleGrantedAuthority::new).toList();
var user = new AuthenticatedUser(claims.get("userId", Integer.class).longValue(),
claims.getSubject(), authorities);
var authentication = new UsernamePasswordAuthenticationToken(
user, null, authorities); // credentials: null, already authenticated
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}Five decisions worth understanding:
The filter never rejects the request. If there is no token, or it is invalid, it simply does not authenticate and lets the chain continue: the one who decides whether that request needed authentication is the AuthorizationFilter from 05-01, and the one who produces the 401 is the AuthenticationEntryPoint from 05-03. This separation is what allows GET /api/v1/stations to remain public while /api/v1/rentals is not, with the same filter.
The database is not queried: all the information —id, email, roles— comes from the token. That is what makes the model stateless and fast, and also what freezes the roles until expiry; the alternative, loading the UserDetails on every request, is safer and sacrifices most of the advantage.
Whether there is already an authentication is checked before writing into the context, so as not to trample another mechanism's. AuthenticatedUser needs a second constructor taking id, email and authorities, with no entity and no hash: getPassword() will return null and that is fine, because on this path nobody compares passwords. And the details are stored with the IP: valuable information for the auditing from 05-03.
- Registering the filter in the security chain
@Bean
SecurityFilterChain apiFilterChain(HttpSecurity http,
JwtAuthenticationFilter jwtFilter,
UnauthenticatedEntryPoint entryPoint,
ApiAccessDeniedHandler accessDenied) throws Exception {
http
.securityMatcher("/api/**")
.csrf(csrf -> csrf.disable()) // justified in 05-02
.cors(Customizer.withDefaults())
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.httpBasic(AbstractHttpConfigurer::disable) // goodbye to the password on every request
.formLogin(AbstractHttpConfigurer::disable)
.logout(AbstractHttpConfigurer::disable) // there is no session to close
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.POST, "/api/v1/auth/**").permitAll()
.anyRequest().denyAll()) // ... and the rest of the 05-02 map
.exceptionHandling(ex -> ex.authenticationEntryPoint(entryPoint)
.accessDeniedHandler(accessDenied))
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class) places the filter at a specific position in the 05-01 chain: after SecurityContextHolderFilter and CorsFilter —so the context is clean and CORS preflight requests have already been resolved— and before ExceptionTranslationFilter and AuthorizationFilter, which is the essential part: by the time authorisation evaluates the rules, the context must already hold the user. And here httpBasic and formLogin disappear, exactly as we announced in 05-02: from now on the only way to authenticate against the API is a token.
POST /api/v1/auth/login
POST /api/v1/auth/loginpublic record LoginRequest(@NotBlank @Email String email, @NotBlank String password) {}
public record TokenResponse(String accessToken, String refreshToken,
String type, long expiresInSeconds) {}
@Service
public class AuthenticationService {
private final AuthenticationManager authenticationManager; // bean from 05-03
private final JwtService jwtService;
private final RefreshTokenService refreshTokenService;
private final JwtProperties properties; // constructor omitted
public TokenResponse authenticate(LoginRequest request) {
// Delegates ALL the validation to the DaoAuthenticationProvider from 05-03:
// hash, active account, lockout and timing protection
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.email().trim().toLowerCase(Locale.ROOT),
request.password()));
var user = (AuthenticatedUser) authentication.getPrincipal();
return new TokenResponse(
jwtService.generateToken(user),
refreshTokenService.issue(user.getUserId()).token(),
"Bearer",
properties.expiration().toSeconds());
}
}The controller is a @PostMapping("/login") in the AuthController from 05-03 that receives @Valid @RequestBody LoginRequest and returns 200 OK with the TokenResponse.
Three points. Authentication is not reimplemented: it is delegated to the AuthenticationManager, which already knows how to compare hashes in constant time, check whether the account is active and publish the failure events; rewriting an if (encoder.matches(...)) here would lose all of that. If it fails it throws BadCredentialsException, which must be translated into a uniform 401 in GlobalExceptionHandler —this one does see it, because it happens inside a controller. And the response reveals nothing about the user: only tokens.
- Refresh tokens: rotation and revocation
There is an obvious tension. A short access token limits the damage of a theft, but it forces the citizen to type their password every fifteen minutes. A long one is convenient and dangerous.
The solution is two tokens with different roles:
| Access token | Refresh token | |
|---|---|---|
| Format and lifetime | Signed JWT, 15 minutes | Opaque random string, 30 days |
| Where it is used | On every API request | Only at /api/v1/auth/refresh |
| Does the server store it? Can it be revoked? | No and no | Yes, in the database; revocable instantly |
The refresh token is not a JWT, and that is deliberate: since the server stores it anyway in order to be able to revoke it, there is no advantage in it being self-contained, and a long random string is shorter, more opaque and reveals nothing.
-- V6__create_refresh_tokens.sql
-- (V5 normalised the emails, in exercise 2 of 05-03)
CREATE SEQUENCE refresh_tokens_id_seq INCREMENT BY 50 START WITH 1;
CREATE TABLE refresh_tokens (
id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
token_hash VARCHAR(64) NOT NULL, -- SHA-256 of the token, never the token
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL,
source_ip VARCHAR(45),
CONSTRAINT pk_refresh_tokens PRIMARY KEY (id),
CONSTRAINT uk_refresh_tokens_hash UNIQUE (token_hash),
CONSTRAINT fk_refresh_tokens_user FOREIGN KEY (user_id)
REFERENCES users (id) ON DELETE CASCADE
);
CREATE INDEX idx_refresh_tokens_user ON refresh_tokens (user_id);
CREATE INDEX idx_refresh_tokens_expires ON refresh_tokens (expires_at);The SHA-256 of the token is stored, not the token, by the same reasoning as with passwords: if somebody reads the table, they do not obtain usable credentials. A fast hash is enough here, because the token is a random 256-bit string and not a password guessable from a dictionary. The refresh endpoint, with rotation:
@Transactional
public TokenResponse refresh(String presentedToken) {
String hash = sha256(presentedToken);
RefreshToken stored = repository.findByTokenHash(hash)
.orElseThrow(() -> new BadCredentialsException("Invalid refresh token"));
if (stored.getRevokedAt() != null) { // reuse: a sign of theft, cut everything off
log.error("REUSE of a refresh token. User {}", stored.getUserId());
repository.revokeAllForUser(stored.getUserId(), Instant.now(clock));
throw new BadCredentialsException("Invalid refresh token");
}
if (stored.getExpiresAt().isBefore(Instant.now(clock)))
throw new BadCredentialsException("Invalid refresh token");
stored.setRevokedAt(Instant.now(clock)); // rotation: the old one dies
var user = loadUser(stored.getUserId()); // fresh roles from the database
return new TokenResponse(jwtService.generateToken(user),
issue(user.getUserId()).token(),
"Bearer", properties.expiration().toSeconds());
}Four key ideas:
Rotation: every use of the refresh token invalidates it and issues a new one. It reduces the window of a stolen token and, above all, makes the theft detectable.
Reuse detection: if an already revoked token arrives, either it is the thief using one the victim has already rotated, or the other way round; there is no way to know which, so all of that user's refresh tokens are revoked and they are forced to authenticate again. It is annoying and it is the right thing to do.
The roles are reloaded from the database, and that is the moment when the privilege change from exercise 3 of 05-03 takes effect: at most fifteen minutes later. The error message is always the same, whether the token does not exist, is revoked or has expired. And one operational piece is missing: a scheduled task that deletes the expired ones, or the table grows without limit (07-03).
- Signing out in a stateless world
POST /api/v1/auth/logout cannot invalidate the access token. It is signed, it is valid and the server keeps nothing about it. The real options:
| Strategy | Effect | Cost |
|---|---|---|
| Delete the token on the client | The client stops sending it | Nil. No protection if it has already been stolen |
| Revoke the refresh token | Within ≤15 min access dies | Low. CicloUrbana's option |
Revocation list by jti |
Immediate | A store consulted on every request: no longer stateless |
CicloUrbana implements signing out as revoking the refresh token: the access token remains valid for up to fifteen minutes and then cannot be renewed. It is a conscious decision, and we must be honest about what it means: during those fifteen minutes, a stolen token keeps working.
When that is not tolerable —a banking operation, a sign-out on suspicion of compromise— the solution is a revocation list of the jtis in a fast cache such as Redis, with automatic expiry when the token expires; consulting it on every request reintroduces state, but bounded state, and distributed caching is covered in 09-02. In any case, the main defence remains the short expiry: everything else is mitigation.
- Where the client stores the token
This is the part of the system the backend does not control and where most real incidents happen.
| Storage | Vulnerable to XSS | Vulnerable to CSRF | Notes |
|---|---|---|---|
localStorage / sessionStorage |
Yes, completely | No | Readable by any JS on the page |
HttpOnly + Secure + SameSite=Strict cookie |
No | Yes, requires CSRF | JS cannot read it |
| JS memory (a variable) | Partially | No | Lost on reload; the refresh in a cookie mitigates it |
| System keychain (mobile) | Not applicable | Not applicable | The best option in a native app |
Warning.
localStorageis the most repeated piece of advice on the internet and the worst. Any XSS —a compromised JavaScript dependency, a badly escaped field— allows the token to be read and the user impersonated from another machine. AnHttpOnlycookie is not readable by JavaScript, not even with an XSS.
CicloUrbana's mobile app, which is the main client, uses the system keychain: Keychain on iOS, EncryptedSharedPreferences or Keystore on Android. For the council's web panel the recommendation is the HttpOnly; Secure; SameSite=Strict cookie with CSRF protection switched back on —remember the warning from 05-02: once the credential goes back into a cookie, the condition that allowed CSRF to be disabled no longer holds— or else the mixed pattern of an access token in memory and a refresh token in an HttpOnly cookie. And in every case, HTTPS is mandatory: without TLS all of the above is irrelevant, because the token travels readable across the network (05-05).
- Documenting the scheme in OpenAPI and the production alternative
The Swagger UI from 03-07 stopped working against the protected endpoints: it has nowhere to type the token. It is fixed by declaring the security scheme in OpenApiConfig:
@Bean
OpenAPI cicloUrbanaApi(...) {
return new OpenAPI()
.info(...) // the same as in 03-07
.addSecurityItem(new SecurityRequirement().addList("bearerAuth"))
.components(new Components().addSecuritySchemes("bearerAuth",
new SecurityScheme().type(SecurityScheme.Type.HTTP)
.scheme("bearer").bearerFormat("JWT")
.description("Token obtained from POST /api/v1/auth/login")));
}The Authorize button then appears in the interface and springdoc adds the header to every call; the public endpoints are marked with an empty @SecurityRequirements so that the documentation does not lie.
The recommended alternative for production
All the code in this lesson is a home-grown implementation: excellent for understanding the mechanism, and in a real system it is better not to write it. Spring offers a maintained, audited module:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>spring:
security:
oauth2:
resourceserver:
jwt:
# The provider publishes its public keys; Spring downloads and rotates them itself
issuer-uri: https://identity.ribalta.example/realms/ciclourbanaWith those lines JwtService, JwtAuthenticationFilter and secret management all disappear: the BearerTokenAuthenticationFilter from 05-01 takes care of it using Nimbus underneath, and the public keys are obtained from the provider's JWKS and rotated automatically.
| Home-grown implementation (this lesson) | Resource Server + provider | |
|---|---|---|
| Code you maintain | ~200 lines of security | Practically none |
| Token issuing and key rotation | Yours, manual | The provider's (Keycloak, Auth0, Cognito), automatic via JWKS |
| Second factor, OIDC, SSO | Would have to be implemented | Included |
| Auditing the code | Your responsibility | The provider's |
| Extra infrastructure | None | One more service |
The criterion: if the system has a single service, one client and simple requirements, a home-grown implementation with a solid library is acceptable. As soon as several services, sign-in with external providers, a second factor or regulatory compliance requirements appear, use an identity provider. The cost of maintaining your own security grows far faster than it looks.
- Testing the complete flow with curl
# 1. Login (after the registration from 05-03): the password travels ONCE
TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"[email protected]","password":"long-example-pass"}' | jq -r .accessToken)
# 3 and 4. Without a token and with a token
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8080/api/v1/rentals # 401
curl -s -o /dev/null -w '%{http_code}\n' \
-H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/rentals # 200
# 5. Inspect the payload (with no secret: proves it is NOT encrypted)
echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq
# {"iss":"ciclourbana","sub":"[email protected]","userId":42,"roles":[...],...}
# 6. Marta is a CITIZEN: station management is off limits to her
curl -s -o /dev/null -w '%{http_code}\n' -X POST \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"name":"Old Market","capacity":20}' \
http://localhost:8080/api/v1/stations # 403
# 7. Tampered token: changing a single character breaks the signature
curl -s -o /dev/null -w '%{http_code}\n' \
-H "Authorization: Bearer ${TOKEN}x" http://localhost:8080/api/v1/rentals # 401Steps 5 and 7 are the important ones: 5 proves that the token is readable by anyone —hence the warning in section 6— and 7, that it is not modifiable.
Common Mistakes and Tips
Putting sensitive data in the payload. It is signed, not encrypted: a national ID number in a JWT is a public national ID number.
Writing the secret into application.yml, or using a short, invented one. A secret in the YAML ends up in Git, in the image and on every team member's laptop; and mysecret123 does not reach 256 bits. Use an environment variable with no default, generated with openssl rand -base64 48. And no 24-hour access tokens "so it does not get in the way": they multiply the window of a theft by 96 and freeze the roles for that whole time.
Having the filter return 401 directly, or registering it at the wrong position in the chain. The first breaks the public endpoints —the filter authenticates if it can, and the decision belongs to the AuthorizationFilter—; the second, if it ends up after AuthorizationFilter, makes everything return 401.
Not validating iss or aud, which means a token issued by another system with the same secret would be accepted; or reading alg from the token to decide how to verify, which is the alg: none vulnerability. The algorithm is pinned by the server.
Storing the refresh token in the clear in the database, or logging any token even truncated. A token is a credential; store its SHA-256 and never write it down.
Tip: short expiry plus refresh with rotation —15 minutes of access, 30 days of rotating refresh with reuse detection— is the balance that works. And use the injected Clock in JwtService: in module 6 you will be able to test expiry by moving the clock forward instead of waiting.
Exercises
Exercise 1
A CicloUrbana token has this payload. Spot four security or design problems and propose the corrected version.
{ "sub": "[email protected]", "name": "Marta Aguiló", "nationalId": "12345678Z",
"passwordHash": "$2a$10$N9qo8uLOickgx2ZMRZoMye...", "userId": 42,
"roles": ["ROLE_CITIZEN"], "totalSpent": 127.50,
"iat": 1773561600, "exp": 1774166400 }Exercise 2
Implement JwtService.validate(String) so that it distinguishes three results —valid token, expired token and invalid token— through a type of your own instead of an Optional, and explain what the filter must do with each and which log level it corresponds to.
Exercise 3
Design the complete flow for this council requirement: "when a citizen changes their password, all their open sessions on other devices must be closed immediately". Bear in mind that the access token cannot be revoked.
Solutions
Solution 1
Problem 1 — nationalId. Identifying personal data, readable by anyone with the token and subject to GDPR. Out. Problem 2 — passwordHash, the most serious failure: it hands the hash to anyone who captures the token, allowing a dictionary attack with no attempt limit and leaving no trace on the server. Out, always.
Problem 3 — totalSpent. Confidential commercial data and, on top of that, a piece of data that changes: it would be frozen until expiry and would show stale figures if the client trusted it. It is looked up through the API. Problem 4 — a seven-day expiry (exp - iat = 604,800): a stolen token is good for a week, and role changes take a week to apply. iss, aud and jti are also missing, and without them the provenance cannot be validated nor can the token be revoked by identifier.
{ "iss": "ciclourbana", "aud": "ciclourbana-api", "sub": "[email protected]",
"userId": 42, "roles": ["ROLE_CITIZEN"],
"iat": 1773561600, "exp": 1773562500,
"jti": "8f2a4c31-5b7e-4d19-9c02-6ea3f1b0d47c" }The rule for deciding what goes in: only what is needed on every request in order to authorise, does not change during the life of the token and would not be a problem on a postcard. The name could stay if the app displays it —it is not sensitive— but it fattens the token: it is better to ask for it once from /api/v1/users/me.
Solution 2
public sealed interface ValidationResult {
record Valid(Claims claims) implements ValidationResult {}
record Expired(String email) implements ValidationResult {}
record Invalid(String reason) implements ValidationResult {}
}
public ValidationResult validate(String token) {
try {
return new ValidationResult.Valid(extractClaims(token));
} catch (ExpiredJwtException e) {
// JJWT's exception keeps the claims: useful for the log and for suggesting a refresh
return new ValidationResult.Expired(e.getClaims().getSubject());
} catch (SecurityException e) {
return new ValidationResult.Invalid("signature");
} catch (MalformedJwtException e) {
return new ValidationResult.Invalid("format");
} catch (JwtException | IllegalArgumentException e) {
return new ValidationResult.Invalid(e.getClass().getSimpleName());
}
}
// In the filter, with Java 21 pattern switch:
switch (jwtService.validate(token)) {
case ValidationResult.Valid v -> authenticate(v.claims(), request);
case ValidationResult.Expired ex -> { // a hint for the client
log.debug("Expired token for {}", ex.email());
response.setHeader("X-Auth-Reason", "token-expired");
}
case ValidationResult.Invalid i -> log.warn("Token rejected ({})", i.reason());
}What the filter does with each case. Valid: it authenticates. Expired: it does not authenticate, but it is not an error either; it is normal operation every fifteen minutes, it goes to DEBUG, and the hint header lets the mobile app tell "renew the token" from "sign in again" without exposing anything. Invalid: it does not authenticate and it goes to WARN, because an incorrect signature is either a tampering attempt or a broken client, and a spike of these events should trigger an alert (09-05). The advantage of the sealed type over the Optional is that it is impossible to forget a case: a switch over a sealed interface must be exhaustive or it does not compile, and in the module 6 tests you can assert on the exact reason.
Solution 3
The problem: the access token on the other devices is valid and there is nothing on the server that invalidates it; revoking the refresh tokens closes the future door, but leaves up to fifteen minutes of live access. Complete solution, in four pieces. 1. An invalidation marker on the user, with a V7__add_token_invalidation.sql migration that runs ALTER TABLE users ADD COLUMN tokens_invalid_before TIMESTAMPTZ;. 2. When changing the password, in the same transaction: store the new hash, set tokensInvalidBefore = now and revoke all of that user's refresh tokens. 3. A check in the filter, taking advantage of the fact that the token's iat says when it was issued: if it is earlier than the marker, the token dies.
Instant issuedAt = claims.getIssuedAt().toInstant();
Instant cutoff = userService.invalidationFor(claims.get("userId", Integer.class));
if (cutoff != null && issuedAt.isBefore(cutoff)) return; // do not authenticate4. The cost, and how to pay it. This reintroduces one query per request, exactly what the stateless model was avoiding. Three ways to soften it, from least to most effort: cache the marker per user with a short expiry (09-02); keep in cache only the users with a recent invalidation, which are very few indeed, and treat absence as "no invalidation"; or include the marker as a claim in the token, although then it only protects from the next refresh onwards and does not meet the requirement.
The underlying lesson: the requirement "sign out immediately everywhere" is incompatible with purely stateless authentication. Every solution pays with state on the server. The honest thing is to acknowledge it, choose where the price is paid and document it, not to pretend that JWT solves everything. And it is worth asking first whether "immediately" really means zero seconds or whether fifteen minutes are acceptable: the answer changes the architecture completely.
Conclusion
CicloUrbana now authenticates like a modern API. You know why a mobile application needs stateless authentication —the client is not a browser, the session does not survive the load balancer and the REST constraint from 03-01 demands it— and you have compared session and token honestly, without hiding the JWT's three real drawbacks: it is not easily revoked, it takes up space on every request and it is dangerous if the client stores it badly. You also know that for a classic website with server-side views the cookie session is still the simplest and safest option: CicloUrbana chooses the token because of its requirements, not because of fashion.
You know a JWT from the inside: header, payload and signature in Base64URL, the HMAC that ties the first two parts to the server's secret, and the central fact that Base64URL is not encryption. You handle the registered claims —iss, sub, aud, exp, iat, jti— and the project's own two, roles and userId, knowing that each of them enlarges the token and freezes a piece of data until expiry. You have compared HS256 with RS256 and you know when it forces an architecture change: as soon as a second service has to verify, because with a shared secret whoever verifies can also issue. And you know the alg: none vulnerability and its defence: the algorithm is decided by the server, never by the token.
You have implemented the complete system with JJWT 0.12: the three dependencies with their scopes, JwtProperties validated with a secret arriving through an environment variable and with no default value —so that the application does not start if it is missing— with a minimum length of 43 Base64 characters and a policy check that prevents configuring tokens lasting more than an hour. JwtService generates, extracts and validates with verifyWith, requireIssuer, requireAudience, clock tolerance and an injected Clock that will make it possible to test expiry in module 6. JwtAuthenticationFilter, a OncePerRequestFilter registered with addFilterBefore in front of UsernamePasswordAuthenticationFilter, never rejects: it authenticates if it can and leaves the decision to the AuthorizationFilter, which is exactly what allows public and protected endpoints to coexist in the same chain. And with POST /api/v1/auth/login delegating to the AuthenticationManager from 05-03, HTTP Basic and the login form have disappeared from the configuration.
You have added refresh tokens with the refresh_tokens table from the V6 migration, which stores the SHA-256 of the token and not the token, with rotation on every use, reuse detection that revokes all of the user's sessions at the first sign of theft, and roles reloaded from the database on every refresh. And you have looked squarely at the two problems that JWT marketing tends to hide: signing out in a stateless world means revoking the refresh token and accepting up to fifteen minutes of live access, or paying with state through a revocation list by jti; and storage on the client, where localStorage is the most repeated advice and the worst, against the system keychain in the mobile app and the HttpOnly; Secure; SameSite cookie —with CSRF switched back on— in the web panel. The Swagger UI from 03-07 works again with its Authorize button, and you know the mature alternative for production: spring-boot-starter-oauth2-resource-server with an identity provider that rotates its keys through JWKS and brings SSO and a second factor as standard.
One hole remains, and it is the same one we noted in 05-01 and 05-03. Marta has a valid token with the ROLE_CITIZEN role, so POST /api/v1/rentals/9/finish passes every rule in the chain: the path is legitimate and the role is correct. But rental number 9 belongs to another citizen. No URL-based rule can express "only your own rentals", because the answer depends neither on the path nor on the role, but on the data. It is the A01/BOLA risk from the OWASP Top 10, the most exploited one in REST APIs. In 05-05, Method-Level Security and API Hardening, we will take security down to the service layer with @EnableMethodSecurity, @PreAuthorize and SpEL expressions, we will write a permission evaluator of our own for the rentals rule, and we will close the module by hardening the whole API: CORS per environment, rate limiting, mandatory HTTPS, hiding the documentation in production, auditing security events, reviewing vulnerable dependencies and a checklist before going to production.
Spring Boot Course
Module 1: Introduction to Spring Boot
- What Is Spring Boot?
- Setting Up Your Development Environment
- Building Your First Spring Boot Application
- Understanding the Project Structure
- Application Startup and Lifecycle
Module 2: Spring Boot Core Concepts
- Spring Boot Annotations
- Dependency Injection in Spring Boot
- Bean Scope and Lifecycle
- Spring Boot Configuration
- Spring Boot Properties
- Auto-Configuration and Starters from the Inside
Module 3: Building RESTful Web Services
- Introduction to RESTful Web Services
- Creating REST Controllers
- Handling HTTP Methods
- Validating Input Data
- DTOs and Mapping Between Layers
- Exception Handling in REST
- Documenting the API with OpenAPI
Module 4: Data Access with Spring Boot
- Introduction to Spring Data JPA
- Configuring Data Sources
- Creating JPA Entities
- Relationships Between Entities
- Using Spring Data Repositories
- Query Methods in Spring Data JPA
- Transactions and Persistence Management
- Schema Migrations with Flyway
Module 5: Security in Spring Boot
- Introduction to Spring Security
- Configuring Spring Security
- User Authentication and Authorization
- Implementing JWT Authentication
- Method-Level Security and API Hardening
Module 6: Testing in Spring Boot
- Introduction to Testing
- Unit Testing with JUnit
- Mocking with Mockito
- Integration Testing
- Testing with Testcontainers
Module 7: Advanced Spring Boot Features
- Spring Boot Actuator
- Spring Boot Profiles
- Scheduled Tasks and Asynchronous Execution
- Spring Boot with Docker
- Spring Boot and Microservices
- Service Communication and Fault Tolerance
Module 8: Deploying Spring Boot Applications
- Introduction to Deployment
- Deploying to Heroku
- Deploying to AWS
- Deploying to Kubernetes
- Continuous Integration and Delivery
Module 9: Performance and Monitoring
- Performance Tuning
- Caching with Spring Cache
- Monitoring with Spring Boot Actuator
- Using Prometheus and Grafana
- Logging and Log Management
- Distributed Tracing
