Access tokens, hashed refresh tokens, rotation, PostgreSQL, Flyway, and Testcontainers
Most JWT tutorials show how to generate a token.
This guide explains how to design the authentication system around it.
Authentication looks deceptively simple in a demo. A client submits an email address and password, the server creates a JWT, and the client uses that token to call protected endpoints.
The difficult questions appear later.
What happens when the access token expires? How does logout work in a stateless system? What if a refresh token is stolen? Should tokens be stored in PostgreSQL? How do multiple devices remain logged in independently? What should happen when an old refresh token is submitted twice? And how do we verify that the entire lifecycle works against a real database?
This guide answers those questions by building a production-minded authentication architecture with:
- Java 21
- Spring Boot 4
- Spring Security
- JWT access tokens
- opaque refresh tokens
- SHA-256 refresh-token hashing
- refresh-token rotation
- BCrypt password hashing
- PostgreSQL
- Flyway
- Docker Compose
- Swagger/OpenAPI
- Testcontainers
The code examples follow the architecture of the Spring Boot JWT Starter Kit and use its actual package structure and service names.
The public demo repository is available at:
https://github.com/teka-it/spring-boot-jwt-starter-demo
Table of Contents
- Why Most JWT Tutorials Are Incomplete
- Access Tokens and Refresh Tokens
- Storing Refresh Tokens Securely
- Refresh-Token Rotation
- Where Tokens Should Live on the Client
- Stateless Security with Spring Security
- Registration and Login
- Implementing the Refresh and Logout Flows
- Common JWT Mistakes
- Testing with PostgreSQL and Testcontainers
- Production Hardening
- The Complete Architecture
- Appendix A — JWT Claims
- Appendix B — Authentication Status Codes
- Appendix C — Authentication Timeline
- Conclusion
Part I — Foundations
1. Why Most JWT Tutorials Are Incomplete
Every week, new tutorials explain how to add JWT authentication to a Spring Boot application.
Most follow roughly the same path:
- Accept a username and password.
- Load the user from a database.
- Generate a JWT.
- Add a filter.
- Protect an endpoint.
The result works. A valid token reaches the API, Spring Security recognizes the caller, and protected resources become accessible.
But generating a JWT is not the same as designing an authentication system.
A real system must continue behaving correctly when:
- an access token expires;
- a user logs in on multiple devices;
- a refresh token is copied;
- a database backup is exposed;
- a user logs out;
- a password is reset;
- an account is disabled;
- two refresh requests arrive at nearly the same time;
- a signing secret needs to be rotated;
- an attacker repeatedly submits old credentials.
A basic JWT tutorial usually stops before these questions become visible.
JWT Is a Token Format, Not a Session Strategy
A JSON Web Token is a signed set of claims. It can carry a subject, issuer, expiration time, and roles. A server can validate those claims without loading session state from a database.
That is useful, but it does not define:
- how long a login should last;
- how credentials should be renewed;
- how sessions should be revoked;
- how logout should work;
- how compromised credentials should be contained.
Those responsibilities belong to the authentication architecture surrounding the JWT.
A useful mental model is:
JWT
└── proves information about one request
Session architecture
├── decides how long login persists
├── decides how credentials are renewed
├── manages revocation
├── manages logout
└── limits damage after compromise
Server Sessions and Stateless Access Tokens
Traditional server-side authentication stores session state on the server.
Traditional server-side session lookup
The browser sends a session identifier. The server loads the corresponding session and determines who the user is.
JWT access tokens invert that model.
Stateless JWT access-token validation
The token carries the information required to authenticate the request. The server validates it cryptographically and does not need to retrieve an access-token record.
That stateless property improves scalability, but it introduces a trade-off: a valid access token is difficult to revoke immediately without adding state back into the request path.
Why One Long-Lived Token Is Not Enough
The simplest JWT design issues one token that remains valid for days or weeks.
That design is pleasant during development because it avoids refresh logic. It is also dangerous.
A bearer token works for whoever possesses it. If a thirty-day access token is copied, the attacker can use it for thirty days. The signature remains valid, and a stateless server has no automatic way to know that the token changed hands.
The safer model separates two concerns:
- short-lived authorization, handled by access tokens;
- long-lived session continuity, handled by refresh tokens.
The access token may remain valid for fifteen minutes. The refresh token may keep the session alive for thirty days, but it is stateful, revocable, stored securely, and rotated after use.
Design Decision — Accept bounded access-token risk
The starter does not persist access tokens. A stolen access token therefore remains usable until it expires. The risk is bounded by a short lifetime, while the normal request path remains stateless and does not require a database lookup.
What to Remember
- A JWT is not a complete authentication system.
- Long-lived access tokens create large attack windows.
- Stateless access-token validation and stateful session management can coexist.
- Refresh tokens exist because access tokens should expire quickly.
- Security depends on the lifecycle around the token, not merely its signature.
2. Access Tokens and Refresh Tokens
Access tokens and refresh tokens are often returned together, but they have different responsibilities, lifetimes, storage requirements, and failure modes.
Access Tokens Authorize API Requests
The client sends an access token with a protected request:
GET /api/users/me HTTP/1.1
Host: localhost:8080
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...
The API validates:
- the cryptographic signature;
- the issuer;
- the expiration time;
- other configured claims.
When validation succeeds, Spring Security establishes an authenticated SecurityContext.
A typical access-token payload might look like:
{
"iss": "spring-boot-jwt-starter",
"sub": "alice@example.com",
"iat": 1785402000,
"exp": 1785402900,
"roles": ["USER"]
}
The starter uses the email address as the subject and stores role names in a custom roles claim.
Refresh Tokens Maintain Sessions
A refresh token is not sent with ordinary API requests. It is presented only to the refresh endpoint:
POST /api/auth/refresh HTTP/1.1
Content-Type: application/json
{
"refreshToken": "a-long-random-url-safe-value"
}
The server hashes the supplied value, finds the matching session record, verifies that it has not expired or been revoked, revokes it, and issues a replacement pair.
The refresh token therefore acts as a stateful session credential.
Different Lifetimes
The starter’s default configuration expresses the difference clearly:
app:
jwt:
issuer: ${JWT_ISSUER:spring-boot-jwt-starter}
secret: ${JWT_SECRET}
access-token-ttl: ${JWT_ACCESS_TOKEN_TTL:15m}
refresh-token-ttl: ${JWT_REFRESH_TOKEN_TTL:30d}
| Credential | Default lifetime | Primary purpose |
|---|---|---|
| Access token | 15 minutes | Authorize protected API requests |
| Refresh token | 30 days | Continue an authenticated session |
Fifteen minutes is not a universal rule, and thirty days is not automatically appropriate for every product. Financial, healthcare, administrative, and consumer applications may choose different values. The important property is the asymmetry: access tokens are deliberately brief, while refresh tokens receive stronger lifecycle controls.
Stateless and Stateful Layers
The architecture combines two models:
Short-lived authorization and stateful session continuity
Most application traffic uses only the access token and does not query the refresh-token table. Authentication-related endpoints use PostgreSQL to create, consume, rotate, and revoke sessions.
Behind the Starter Kit
The public API returns a
TokenResponsecontaining an access token, refresh token, token type, and access-token lifetime. The access token is a signed JWT. The refresh token is an opaque random value with no claims for the client to inspect.
Multiple Devices
Each successful login creates a new refresh-token record.
A user can therefore have separate sessions for:
- a personal laptop;
- a mobile phone;
- a work computer.
The current starter revokes a specific refresh token during logout. It does not yet expose a session-management screen or “log out everywhere” endpoint, but its one-record-per-token model provides a foundation for those features.
What to Remember
- Access tokens authorize requests.
- Refresh tokens preserve login continuity.
- Access tokens are stateless and short-lived.
- Refresh tokens are stateful and long-lived.
- Each login can create an independent device session.
3. Storing Refresh Tokens Securely
A refresh token can generate new access tokens. That makes it a high-value credential.
Storing it in plaintext would turn a database read breach into immediate session compromise.
The Plaintext Problem
Consider this table:
CREATE TABLE refresh_token (
id BIGSERIAL PRIMARY KEY,
token VARCHAR(255) NOT NULL UNIQUE,
user_id BIGINT NOT NULL,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL
);
The schema itself does not reveal whether token contains plaintext or a hash. If the raw value is stored, anyone who can read the table can submit it to /api/auth/refresh.
Encryption at rest helps protect disks and backups, but the application still needs the ability to decrypt the value. A one-way hash provides a stronger property: the server does not need to recover the original token at all.
Generate a High-Entropy Opaque Token
The starter generates 32 random bytes using SecureRandom:
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
byte[] bytes = new byte[32];
SECURE_RANDOM.nextBytes(bytes);
String rawToken = Base64.getUrlEncoder()
.withoutPadding()
.encodeToString(bytes);
Thirty-two bytes provide 256 bits of randomness before encoding. URL-safe Base64 makes the result convenient to transport in JSON, headers, or cookies.
The token is opaque. It contains no user ID, timestamp, role, or other claims. The client only needs to preserve and return it.
Hash Before Persistence
The raw value is hashed with SHA-256:
private String hash(String value) {
try {
byte[] digest = MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(digest);
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException(
"SHA-256 is niet beschikbaar",
exception
);
}
}
The stored entity receives the hash:
RefreshToken refreshToken = new RefreshToken();
refreshToken.setToken(hash(rawToken));
refreshToken.setUser(user);
refreshToken.setExpiresAt(
OffsetDateTime.now().plus(jwtProperties.refreshTokenTtl())
);
refreshTokenRepository.save(refreshToken);
return rawToken;
The raw token is returned to the client. Only the 64-character hexadecimal SHA-256 digest remains in PostgreSQL.
Generating and hashing an opaque refresh token
Why SHA-256 Instead of BCrypt?
Passwords and refresh tokens are different kinds of secrets.
Passwords are usually:
- chosen by humans;
- low entropy;
- reused;
- vulnerable to dictionary attacks.
BCrypt deliberately makes each guess expensive.
Refresh tokens should be:
- generated by a cryptographically secure random generator;
- high entropy;
- unique;
- infeasible to guess.
A slow password hash provides little additional protection against brute-forcing a genuinely random 256-bit token, while making every lookup more expensive.
SHA-256 also enables a direct indexed lookup:
refreshTokenRepository.findByToken(hash(rawToken))
BCrypt normally uses a random salt, so the same input does not produce a stable lookup value. Applications using BCrypt for tokens often have to load candidate records and call matches, which is less efficient and more complex.
Design Decision — Match the hash to the secret
BCrypt is appropriate for human passwords. SHA-256 is appropriate here because the refresh token is already a high-entropy random credential and the application needs a deterministic lookup key.
Database Shape
The starter uses this migration:
CREATE TABLE refresh_token (
id BIGSERIAL PRIMARY KEY,
token VARCHAR(255) NOT NULL UNIQUE,
user_id BIGINT NOT NULL REFERENCES app_user(id) ON DELETE CASCADE,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
revoked BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
The JPA entity constrains the token field to 64 characters, matching the hexadecimal SHA-256 output:
@Column(nullable = false, unique = true, length = 64)
private String token;
The migration allows 255 characters while the entity declares 64. This is not functionally unsafe, but aligning both definitions to 64 would make the schema more precise.
What Hashing Does Not Solve
Hashing protects refresh tokens at rest. It does not protect a raw token that is stolen from:
- browser JavaScript storage;
- malware on the client device;
- application logs;
- network traffic without HTTPS;
- an insecure analytics or error-reporting integration.
That risk is addressed by secure client storage, HTTPS, careful logging, and token rotation.
What to Remember
- Never store reusable refresh tokens in plaintext.
- Generate tokens with a cryptographically secure random generator.
- Store only a deterministic cryptographic hash.
- BCrypt protects weak human passwords; SHA-256 fits high-entropy random tokens.
- Hashing at rest does not replace secure delivery and client storage.
4. Refresh-Token Rotation
A static refresh token remains useful until it expires or is revoked. If it is copied, the attacker and legitimate client can both use it.
Rotation changes the token after every successful refresh.
Single-Use Credentials
Assume a login produces:
Access token: A1
Refresh token: R1
When the client submits R1, the server:
- validates it;
- marks its database record as revoked;
- creates
R2; - stores the hash of
R2; - creates
A2; - returns
A2andR2.
Refresh-token rotation from R1 to R2
A subsequent attempt to use R1 fails.
How the Starter Consumes a Token
The RefreshTokenService performs the state transition:
@Transactional
public RefreshToken consume(String rawToken) {
RefreshToken token = refreshTokenRepository
.findByToken(hash(rawToken))
.orElseThrow(InvalidRefreshTokenException::new);
if (token.isRevoked()
|| token.getExpiresAt().isBefore(OffsetDateTime.now())) {
throw new InvalidRefreshTokenException();
}
token.setRevoked(true);
return token;
}
The record is retained and marked as revoked rather than deleted.
This has useful audit value: the database can distinguish a token that never existed from a token that existed and was consumed. The public error remains intentionally generic.
Rotation in One Transaction
TokenRefreshService.refresh is transactional:
@Transactional
public TokenResponse refresh(String rawRefreshToken) {
RefreshToken consumedToken =
refreshTokenService.consume(rawRefreshToken);
var user = consumedToken.getUser();
var authorities = user.getRoles().stream()
.map(role -> new SimpleGrantedAuthority(
"ROLE_" + role.getName().name()
))
.toList();
var authentication =
UsernamePasswordAuthenticationToken.authenticated(
user.getEmail(),
null,
authorities
);
return new TokenResponse(
jwtTokenService.createAccessToken(authentication),
refreshTokenService.create(user),
"Bearer",
jwtProperties.accessTokenTtl().toSeconds()
);
}
The old token is revoked and the new record is created within the transaction boundary. If persistence fails, the state changes roll back together.
Common Pitfall — Partial rotation
Revoking the old token and creating the replacement in separate transactions can leave the session in an inconsistent state. The user may lose the session, or two usable tokens may coexist.
Rotation Reduces the Attack Window
Suppose an attacker steals R1.
If the legitimate client refreshes first, R1 becomes revoked and the attacker’s later request fails.
If the attacker refreshes first, the attacker receives R2, while the legitimate client’s next use of R1 fails.
Rotation therefore does not magically identify who is legitimate. It makes token reuse observable and limits the period in which the copied credential remains usable.
Reuse Detection: Current Behavior and a Stronger Variant
Version 1.0.0 rejects reuse because revoked records remain in the database and consume checks isRevoked().
It currently returns the same InvalidRefreshTokenException for:
- an unknown token;
- an expired token;
- a revoked token.
That is a good public response because it avoids leaking unnecessary information.
A more advanced internal policy could distinguish reuse in security telemetry and respond by:
- revoking every active refresh token for the user;
- requiring a new login;
- recording IP address and user agent;
- notifying the user;
- creating a security alert.
That stronger response requires retaining enough session-family information to identify related tokens. The current model does not yet store a family ID or replacement-token relationship.
Production Tip — Separate public errors from internal signals
Return a generic 401 response to the client, but record whether the failure was caused by expiration, revocation, or reuse in structured security logs.
Concurrency
Two refresh requests carrying the same valid token could arrive concurrently.
Both transactions might read the record before either commits the revoked update. A robust high-concurrency system should make consumption atomic, for example through:
- pessimistic locking;
- an atomic conditional update;
- optimistic locking with a version column;
- a database constraint combined with session-family modeling.
The starter’s implementation is suitable as a clear foundation, but applications expecting concurrent refresh traffic should explicitly test and harden this race.
What to Remember
- Treat refresh tokens as single-use credentials.
- Revoke the old token before completing rotation.
- Keep rotation transactional.
- Reuse is a security signal, not merely a validation error.
- Concurrency control matters when multiple refreshes can race.
5. Where Tokens Should Live on the Client
The server can issue credentials securely and still lose the session through unsafe client storage.
There is no browser storage option that removes every risk. The correct choice depends on architecture, threat model, and client type.
Local Storage
Local Storage is convenient:
localStorage.setItem("accessToken", response.accessToken);
localStorage.setItem("refreshToken", response.refreshToken);
Any JavaScript running in the page can also read those values. An XSS vulnerability may therefore exfiltrate the long-lived refresh token.
Session Storage has a shorter persistence model, but the same JavaScript accessibility problem.
HttpOnly Cookies
An HttpOnly cookie cannot be read through normal page JavaScript:
Set-Cookie: refreshToken=...; HttpOnly; Secure; SameSite=Strict; Path=/api/auth
This reduces direct token theft through XSS, although malicious JavaScript may still perform actions as the user while it is executing.
Cookies are sent automatically by the browser, so the application must also consider CSRF. SameSite, origin checks, narrowly scoped cookie paths, and CSRF tokens can be part of that defense.
A Practical Browser Pattern
A common browser architecture is:
- keep the access token in memory;
- deliver the refresh token through an
HttpOnly,Securecookie; - use a narrowly scoped refresh endpoint;
- issue a new in-memory access token after page reload.
A practical browser token-storage pattern
This pattern is not implemented automatically by the starter. Version 1.0.0 accepts and returns the refresh token in JSON so that the backend remains client-agnostic. The README explicitly recommends considering an HttpOnly, Secure cookie for browser frontends.
Non-Browser Clients
Mobile, desktop, and machine clients have different storage mechanisms:
- mobile keychains or keystores;
- operating-system credential vaults;
- encrypted application storage;
- workload identity rather than refresh tokens for machine-to-machine communication.
JWT design should not assume that every client is a browser.
What to Remember
- Browser storage is part of authentication security.
- Local Storage exposes tokens to JavaScript.
- HttpOnly cookies reduce direct token exfiltration but require CSRF consideration.
- Keeping access tokens in memory can reduce persistence.
- The starter’s JSON transport is intentionally client-neutral.
Part II — Implementation
6. Stateless Security with Spring Security
The starter does not implement a custom JWT filter. It uses Spring Security’s OAuth2 resource-server support for bearer-token parsing and JWT validation.
That is an important architectural choice: security-sensitive token handling is delegated to established framework components.
SecurityFilterChain
The central configuration is:
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http)
throws Exception {
return http
.csrf(csrf -> csrf.disable())
.sessionManagement(session ->
session.sessionCreationPolicy(
SessionCreationPolicy.STATELESS
)
)
.authorizeHttpRequests(auth -> auth
.requestMatchers(
"/api/health",
"/api/auth/register",
"/api/auth/login",
"/api/auth/refresh",
"/api/auth/logout",
"/v3/api-docs/**",
"/swagger-ui.html",
"/swagger-ui/**"
).permitAll()
.anyRequest().authenticated()
)
.oauth2ResourceServer(resourceServer ->
resourceServer.jwt(jwt ->
jwt.jwtAuthenticationConverter(
jwtAuthenticationConverter()
)
)
)
.build();
}
Stateless Session Management
.sessionManagement(session ->
session.sessionCreationPolicy(
SessionCreationPolicy.STATELESS
)
)
Spring Security will not use an HTTP session to preserve authentication between requests. Every protected request must carry its own bearer token.
Public and Protected Routes
Registration, login, refresh, logout, health, and API documentation are public. Every other route is authenticated.
This default-deny shape is safer than individually remembering to protect each new controller.
The fact that /api/auth/logout is public at the filter-chain level does not mean it performs no authentication. It authenticates the session by validating the supplied refresh token. The endpoint does not require a still-valid access token, which allows a client to log out after access-token expiration.
JWT Encoder and Decoder
The starter uses HS256 and requires a Base64-encoded secret of at least 32 bytes:
@Bean
SecretKey jwtSecretKey(JwtProperties properties) {
byte[] keyBytes =
Base64.getDecoder().decode(properties.secret());
if (keyBytes.length < 32) {
throw new IllegalStateException(
"JWT_SECRET moet minimaal 256 bits (32 bytes) bevatten"
);
}
return new SecretKeySpec(keyBytes, "HmacSHA256");
}
The encoder and decoder use Spring Security’s Nimbus integration:
@Bean
JwtEncoder jwtEncoder(SecretKey secretKey) {
return NimbusJwtEncoder.withSecretKey(secretKey)
.algorithm(MacAlgorithm.HS256)
.build();
}
@Bean
JwtDecoder jwtDecoder(
SecretKey secretKey,
JwtProperties properties
) {
NimbusJwtDecoder decoder =
NimbusJwtDecoder.withSecretKey(secretKey)
.macAlgorithm(MacAlgorithm.HS256)
.build();
decoder.setJwtValidator(
JwtValidators.createDefaultWithIssuer(
properties.issuer()
)
);
return decoder;
}
createDefaultWithIssuer adds standard validation and checks the configured issuer.
Design Decision — Framework validation instead of a custom filter
A custom
OncePerRequestFiltercan work, but it creates more security-sensitive code for the application to own. The starter relies on Spring Security’s resource-server machinery for token extraction, decoding, claim validation, authentication failures, andSecurityContextintegration.
Mapping Roles
JWT roles are converted into Spring authorities:
@Bean
JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtAuthenticationConverter converter =
new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(jwt -> {
List<String> roles =
jwt.getClaimAsStringList("roles");
if (roles == null) {
return List.of();
}
return roles.stream()
.<GrantedAuthority>map(role ->
new SimpleGrantedAuthority(
"ROLE_" + role
)
)
.toList();
});
return converter;
}
The JWT contains USER; Spring Security receives ROLE_USER.
Creating Access Tokens
JwtTokenService keeps access-token creation focused:
public String createAccessToken(Authentication authentication) {
Instant issuedAt = Instant.now();
Instant expiresAt =
issuedAt.plus(properties.accessTokenTtl());
List<String> roles =
authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.map(authority ->
authority.replaceFirst("^ROLE_", "")
)
.toList();
JwtClaimsSet claims = JwtClaimsSet.builder()
.issuer(properties.issuer())
.issuedAt(issuedAt)
.expiresAt(expiresAt)
.subject(authentication.getName())
.claim("roles", roles)
.build();
return jwtEncoder.encode(
JwtEncoderParameters.from(claims)
).getTokenValue();
}
The service does not know about HTTP, refresh tokens, repositories, or password verification.
What to Remember
- Use stateless session management for bearer-token APIs.
- Prefer framework-supported JWT validation over unnecessary custom filters.
- Validate the issuer as well as signature and time-based claims.
- Convert JWT claims into Spring authorities in one clear place.
- Keep token creation separate from login and refresh orchestration.
7. Registration and Login
A token should only be issued after Spring Security has verified the credentials.
Registration
The starter’s registration flow:
- normalizes the email address;
- validates the request;
- rejects duplicate email addresses;
- hashes the password with BCrypt;
- assigns the
USERrole; - persists the user.
The BCrypt encoder is configured as a bean:
@Bean
PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
Applications should never compare plaintext passwords manually or store them in reversible form.
DatabaseUserDetailsService
Spring Security loads users through the database-backed service:
@Override
@Transactional(readOnly = true)
public UserDetails loadUserByUsername(String email)
throws UsernameNotFoundException {
User user = userRepository
.findByEmailIgnoreCase(email)
.orElseThrow(() ->
new UsernameNotFoundException(
"Gebruiker niet gevonden"
)
);
var authorities = user.getRoles().stream()
.map(role -> new SimpleGrantedAuthority(
"ROLE_" + role.getName().name()
))
.toList();
return new org.springframework.security.core.userdetails.User(
user.getEmail(),
user.getPassword(),
user.isEnabled(),
true,
true,
true,
authorities
);
}
This adapter translates the application’s User entity into Spring Security’s UserDetails.
LoginService
The real login orchestration is concise:
@Transactional
public TokenResponse login(LoginRequest request) {
String email = request.email()
.trim()
.toLowerCase(Locale.ROOT);
var authentication =
authenticationManager.authenticate(
UsernamePasswordAuthenticationToken
.unauthenticated(
email,
request.password()
)
);
User user = userRepository
.findByEmailIgnoreCase(email)
.orElseThrow(() ->
new IllegalStateException(
"Ingelogde gebruiker bestaat niet"
)
);
return new TokenResponse(
jwtTokenService.createAccessToken(authentication),
refreshTokenService.create(user),
"Bearer",
jwtProperties.accessTokenTtl().toSeconds()
);
}
The sequence is deliberate:
- normalize the email;
- delegate credential verification to
AuthenticationManager; - load the domain entity needed to create the refresh-token relation;
- create the access token;
- create and persist the refresh-token hash;
- return both credentials.
Why Load the User Again?
The authenticated principal contains the email, password hash, enabled state, and authorities, but RefreshToken has a JPA relationship to the application’s User entity.
The service therefore loads the entity after authentication.
A future optimization could use a custom principal carrying the user ID or domain reference, but the current implementation is explicit and easy to understand.
AuthController
The HTTP layer delegates instead of implementing business rules:
@PostMapping("/login")
TokenResponse login(
@Valid @RequestBody LoginRequest request
) {
return loginService.login(request);
}
A successful response resembles:
{
"accessToken": "eyJhbGciOiJIUzI1NiJ9...",
"refreshToken": "mh8dJ2...url-safe-random-value",
"tokenType": "Bearer",
"expiresIn": 900
}
Avoid Sensitive Logging
Login requests contain passwords, and token responses contain bearer credentials.
Do not log:
- request bodies for authentication endpoints;
- Authorization headers;
- raw refresh tokens;
- JWTs in exception messages;
- complete cookies carrying session credentials.
Structured logs should contain non-secret identifiers and outcomes, not reusable credentials.
What to Remember
- Authenticate before generating tokens.
- Let
AuthenticationManagerandPasswordEncoderverify passwords. - Normalize identity fields consistently.
- Keep controllers thin.
- Never place passwords or raw tokens in logs.
8. Implementing Refresh and Logout
The refresh endpoint authenticates an existing session rather than a password.
Controller Endpoints
@PostMapping("/refresh")
TokenResponse refresh(
@Valid @RequestBody RefreshTokenRequest request
) {
return tokenRefreshService.refresh(
request.refreshToken()
);
}
@PostMapping("/logout")
ResponseEntity<Void> logout(
@Valid @RequestBody RefreshTokenRequest request
) {
tokenRefreshService.logout(request.refreshToken());
return ResponseEntity.noContent().build();
}
Complete Refresh Flow
The complete refresh flow
The service reconstructs an Authentication from the user associated with the consumed refresh token. This lets JwtTokenService use the same access-token creation method for login and refresh.
Logout
Logout calls:
@Transactional
public void revoke(String rawToken) {
refreshTokenRepository
.findByToken(hash(rawToken))
.ifPresent(token -> token.setRevoked(true));
}
The operation is idempotent from the caller’s perspective. An unknown token does not reveal whether a session existed.
The endpoint returns 204 No Content.
What Logout Can and Cannot Do
Logout revokes the refresh token, preventing future access-token renewal.
It does not invalidate an access token that has already been issued. That token may remain usable until its short expiration time.
This is a normal consequence of stateless access tokens.
Applications requiring immediate access-token revocation must introduce additional state, such as:
- a denylist;
- a user-level security version;
- introspection;
- very short lifetimes combined with gateway controls.
Those options add cost and complexity and should be selected deliberately.
Exception Handling
Invalid refresh tokens become a stable 401 response through GlobalExceptionHandler:
@ExceptionHandler(InvalidRefreshTokenException.class)
ResponseEntity<ApiError> handleInvalidRefreshToken(
InvalidRefreshTokenException exception,
HttpServletRequest request
) {
return buildError(
HttpStatus.UNAUTHORIZED,
exception.getMessage(),
request.getRequestURI(),
Map.of()
);
}
Validation failures become 400 responses, and duplicate registration becomes 409 Conflict.
Common Pitfall — Returning 500 for expected authentication failures
Expired, malformed, revoked, and missing credentials are expected client-facing failures. They should not be reported as unexpected server errors.
What to Remember
- Refresh authenticates a session, not a password.
- Consume and replace the refresh token transactionally.
- Logout revokes renewal capability, not already-issued access tokens.
- Keep authentication failures generic to clients.
- Use correct HTTP status codes and stable error bodies.
Part III — Reliability and Production
9. Common JWT Mistakes
Mistake 1 — Long-Lived Access Tokens
A long-lived access token avoids refresh logic but magnifies the effect of theft.
Better: short-lived access tokens plus managed refresh sessions.
Mistake 2 — Persisting Access Tokens
Looking up every JWT in the database recreates server-side sessions in a less direct form.
Better: validate access tokens statelessly and persist only refresh-session state.
Mistake 3 — Plaintext Refresh Tokens
A database reader can immediately impersonate active sessions.
Better: store SHA-256 hashes of high-entropy opaque tokens.
Mistake 4 — Weak Randomness
Timestamps, UUID variants used without analysis, counters, or java.util.Random should not generate bearer credentials.
Better: SecureRandom with sufficient bytes.
Mistake 5 — BCrypt for Deterministic Token Lookup
BCrypt is intentionally slow and salted. That fits passwords, not direct lookup of high-entropy tokens.
Better: SHA-256 for the random refresh tokens used by this architecture.
Mistake 6 — Static Refresh Tokens
A copied token remains valid for its full lifetime.
Better: rotate after every successful use.
Mistake 7 — Validating Only the Signature
A valid signature does not make an expired token acceptable, and a token from the wrong issuer should not be trusted.
Better: validate time-based claims and issuer; add audience validation when the token is intended for a specific API.
Mistake 8 — Oversized JWT Payloads
JWT payloads are encoded, not encrypted. Clients and intermediaries can read claims.
Better: include only stable authorization data and non-sensitive identifiers.
Mistake 9 — Hardcoded Signing Secrets
Secrets committed to Git may survive in history even after deletion.
Better: inject secrets through environment configuration or a secrets manager.
Mistake 10 — Logging Tokens
Bearer credentials in logs may be copied into search systems, support tools, and backups.
Better: redact Authorization headers, cookies, passwords, and token fields.
Mistake 11 — Ignoring Refresh Races
Two concurrent requests may consume the same token without atomic database controls.
Better: use locking or an atomic update when the workload requires it.
Mistake 12 — Treating CORS as Authentication
CORS is a browser policy. It does not stop non-browser clients from calling the API.
Better: authenticate and authorize every protected server request.
Mistake 13 — Assuming Logout Revokes JWTs
Deleting or revoking the refresh token does not make a signed access token disappear.
Better: communicate this behavior clearly and keep the access-token lifetime short.
Mistake 14 — Returning Detailed Security Errors
Telling an attacker exactly whether a user exists, a token expired, or a token was revoked may help enumeration.
Better: return generic public messages and preserve detailed reasons in internal telemetry.
What to Remember
- Convenience shortcuts often enlarge the attack surface.
- Keep access tokens small, short-lived, and stateless.
- Treat refresh tokens as managed, single-use session credentials.
- Never expose secrets through source control or logs.
- Test failure paths, not only successful login.
10. Testing with PostgreSQL and Testcontainers
Authentication code is easy to test incompletely.
A mocked unit test may prove that one method invokes another. It does not prove that:
- Flyway creates the expected schema;
- JPA mappings match PostgreSQL;
- password authentication works through Spring Security;
- bearer-token validation reaches protected endpoints;
- token rotation is persisted;
- reused tokens are rejected;
- logout prevents later refresh.
The starter therefore includes an end-to-end integration test against a real temporary PostgreSQL instance.
Testcontainer Setup
@Testcontainers
@SpringBootTest(
webEnvironment =
SpringBootTest.WebEnvironment.RANDOM_PORT
)
class AuthenticationFlowIntegrationTest {
@Container
static final PostgreSQLContainer postgres =
new PostgreSQLContainer("postgres:17-alpine")
.withDatabaseName("jwt_starter_test")
.withUsername("jwt_test")
.withPassword("jwt_test");
}
Dynamic properties connect Spring Boot to the container:
@DynamicPropertySource
static void configureProperties(
DynamicPropertyRegistry registry
) {
registry.add(
"spring.datasource.url",
postgres::getJdbcUrl
);
registry.add(
"spring.datasource.username",
postgres::getUsername
);
registry.add(
"spring.datasource.password",
postgres::getPassword
);
registry.add(
"app.jwt.secret",
() -> JWT_SECRET
);
registry.add(
"app.jwt.access-token-ttl",
() -> "15m"
);
registry.add(
"app.jwt.refresh-token-ttl",
() -> "30d"
);
}
The Complete Lifecycle Test
The test performs the same actions as a real client:
- register a unique user;
- log in;
- call
/api/users/mewith the bearer token; - refresh the session;
- verify the old refresh token is rejected;
- log out using the new refresh token;
- verify the logged-out token is rejected.
The central assertions include:
TokenPayload refreshed = postForToken(
"/api/auth/refresh",
Map.of("refreshToken", firstRefreshToken)
);
String secondRefreshToken =
refreshed.refreshToken();
assertThat(secondRefreshToken)
.isNotBlank()
.isNotEqualTo(firstRefreshToken);
Then reuse is rejected:
client.post()
.uri("/api/auth/refresh")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of(
"refreshToken",
firstRefreshToken
))
.exchange()
.expectStatus()
.isUnauthorized();
And logout is verified:
client.post()
.uri("/api/auth/logout")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of(
"refreshToken",
secondRefreshToken
))
.exchange()
.expectStatus()
.isNoContent();
Protected Endpoint Test
A separate test verifies that unauthenticated access is rejected:
@Test
void protectedEndpointRejectsMissingToken() {
client.get()
.uri("/api/users/me")
.exchange()
.expectStatus()
.isUnauthorized();
}
Additional Tests Worth Adding
The included test covers the primary lifecycle. A production application should expand it with:
- invalid password;
- unknown email address;
- duplicate registration;
- malformed JWT;
- expired access token;
- wrong issuer;
- modified JWT signature;
- expired refresh token;
- disabled account;
- role-based authorization;
- concurrent refresh attempts;
- invalid request payloads;
- database uniqueness and cleanup behavior.
Production Tip — Test time explicitly
Token tests become easier and less flaky when services depend on an injectable
Clockinstead of callingInstant.now()orOffsetDateTime.now()directly.
Why a Real Database Matters
H2 and PostgreSQL do not behave identically. Migrations, timestamp handling, constraints, identity columns, and SQL dialect details can differ.
Testcontainers gives the test suite the same database family used in production without requiring a permanently shared test database.
What to Remember
- Test the authentication lifecycle through HTTP.
- Run migrations against a real PostgreSQL container.
- Assert rejection after rotation and logout.
- Test malformed and expired credentials.
- Add concurrency tests before relying on single-use guarantees under load.
11. Production Hardening
The starter is a foundation, not a substitute for application-specific security design.
The following controls belong around the core architecture.
HTTPS Everywhere
Bearer credentials must never travel over plaintext HTTP outside local development.
Enforce TLS at the load balancer or ingress layer, redirect HTTP, and consider HSTS for browser deployments.
Secret Management
The starter reads JWT_SECRET from configuration:
secret: ${JWT_SECRET}
Generate a Base64-encoded value containing at least 32 random bytes:
openssl rand -base64 32
Do not commit production secrets to:
- Git;
- Docker images;
- sample configuration;
- CI logs;
- support tickets.
Use a deployment platform’s secret store or a dedicated secrets manager.
Symmetric vs Asymmetric Signing
HS256 is simple and appropriate when the same trusted application boundary signs and validates tokens.
As systems grow, asymmetric algorithms can provide cleaner separation:
- the authorization service holds the private key;
- APIs validate with public keys;
- validators cannot mint tokens.
A public-key architecture also supports key identifiers and published JWK sets more naturally.
Key Rotation
A single static key creates operational risk.
A mature deployment should support:
- key identifiers (
kid); - overlap between old and new validation keys;
- controlled token-signing rollover;
- emergency rotation procedures;
- audit trails for key access.
Audience Validation
The starter validates the issuer. Applications issuing tokens for a particular API should consider an aud claim and corresponding audience validation.
This prevents a token intended for one service from being accepted by another service that trusts the same issuer or key.
Rate Limiting and Abuse Controls
Protect:
- registration;
- login;
- refresh;
- password-reset endpoints.
Controls may include:
- per-IP limits;
- per-account limits;
- exponential backoff;
- CAPTCHA after suspicious behavior;
- temporary lockouts designed to avoid denial-of-service abuse.
CORS
Configure only the origins, methods, and headers required by trusted browser clients. Avoid broad wildcard settings when credentials or sensitive APIs are involved.
Remember that CORS does not protect the API from scripts, mobile applications, or command-line clients outside a browser.
Cookies and CSRF
When refresh tokens are delivered in cookies:
- set
HttpOnly; - set
Secure; - choose
SameSiteintentionally; - restrict
Path; - consider CSRF tokens or origin verification;
- define cookie expiration consistently with server-side session expiration.
Content Security Policy
A strong CSP reduces the chance and impact of script injection. It complements HttpOnly cookies and secure frontend engineering.
Logging and Monitoring
Record security-relevant events without recording credentials:
- login success and failure;
- refresh success;
- expired or revoked refresh attempts;
- logout;
- account disablement;
- role changes;
- suspicious request rates.
Useful context may include:
- internal user ID;
- session ID;
- timestamp;
- IP address;
- user agent;
- request correlation ID;
- reason category.
Review privacy and retention requirements before storing device or network metadata.
Session Metadata
The current refresh-token entity stores:
- hash;
- user;
- expiration;
- revoked flag;
- creation time.
Possible additions include:
- session ID;
- token family ID;
- last-used time;
- device label;
- IP address;
- user agent;
- revocation reason;
- replaced-by token ID.
These fields enable session dashboards and stronger reuse response.
Cleanup
Expired and revoked rows accumulate over time.
Add a scheduled cleanup process with a retention policy. You may retain revoked records briefly for investigation while deleting old data after the security and audit window closes.
Add indexes that match actual queries and cleanup operations.
Account-State Changes
Consider revoking sessions when:
- a password changes;
- multi-factor authentication settings change;
- an account is disabled;
- a role is removed;
- an administrator initiates a security reset.
Because access tokens remain valid until expiration, the access-token lifetime defines the maximum delay unless you introduce a user security version or another stateful check.
Error Handling
Return consistent, limited client errors. Avoid stack traces and database details.
The starter’s ApiError model provides a useful base for:
- status;
- reason;
- message;
- request path;
- timestamp;
- field validation errors.
Time and Clock Skew
JWT validation depends on reliable clocks.
Use synchronized infrastructure time and define a small, intentional clock-skew policy. Excessive tolerance weakens expiration guarantees.
Dependency Maintenance
Authentication depends on Spring Security, Nimbus JOSE/JWT through Spring, the PostgreSQL driver, Flyway, and other libraries.
Keep dependencies current, monitor advisories, and run automated tests during upgrades.
What to Remember
- Production security is layered.
- Manage signing keys as critical secrets.
- Add audience validation when tokens target a specific API.
- Rate-limit authentication endpoints.
- Log security outcomes, never bearer credentials.
- Plan session cleanup, key rotation, and incident response before they are needed.
12. The Complete Architecture
The final system combines stateless authorization with stateful session continuity.
Complete Spring Boot JWT authentication architecture
Complete Lifecycle
Registration
- The client submits validated profile data.
- The server normalizes the email.
- BCrypt hashes the password.
- The
USERrole is assigned. - Flyway-managed PostgreSQL tables store the account.
Login
- The client submits email and password.
-
AuthenticationManagerdelegates to the configured authentication provider. -
DatabaseUserDetailsServiceloads the account and roles. - BCrypt verifies the password.
-
JwtTokenServiceissues a fifteen-minute access token. -
RefreshTokenServicegenerates 32 random bytes. - The server stores only the SHA-256 hash.
- The client receives the token pair.
Protected Request
- The client sends
Authorization: Bearer <access token>. - Spring Security’s resource server extracts the JWT.
-
JwtDecodervalidates HS256, standard claims, and issuer. -
JwtAuthenticationConvertermaps roles. - Spring Security establishes the
SecurityContext. - The controller handles the authenticated request.
- No access-token database lookup occurs.
Refresh
- The client submits the raw refresh token.
- The server hashes it.
- PostgreSQL returns the matching session record.
- The service verifies expiration and revocation.
- The old record is marked revoked.
- A new access JWT is issued.
- A new random refresh token is generated.
- Its SHA-256 hash is stored.
- The new pair is returned in the same transaction.
Logout
- The client submits its current refresh token.
- The server hashes it.
- The matching record is marked revoked.
- The endpoint returns 204.
- The access token expires naturally.
Project Structure
src/main/java/nl/javalaunch/starter
├── auth
│ ├── controller
│ ├── dto
│ └── service
├── common
│ └── exception
├── config
├── health
├── refreshtoken
│ ├── entity
│ ├── repository
│ └── service
├── role
│ ├── entity
│ └── repository
├── security
└── user
├── controller
├── dto
├── entity
└── repository
This feature-oriented organization keeps HTTP, orchestration, persistence, and security concerns discoverable without forcing every class into generic global controller, service, and repository folders.
Behind the Starter Kit
The full starter includes:
- complete source code;
- PostgreSQL migrations;
- Docker Compose;
- OpenAPI documentation;
- sample API requests;
- integration tests;
- architecture and security notes;
- configuration and customization guides.
The public demo intentionally exposes the architecture and usage examples without redistributing the commercial source code.
Appendix A — JWT Claims
iss — Issuer
Identifies the authority that issued the token.
The starter uses:
spring-boot-jwt-starter
The decoder validates this value.
sub — Subject
Identifies the principal represented by the token.
The starter uses the normalized email address.
For systems where email addresses can change, an immutable user ID may be a stronger long-term subject.
iat — Issued At
Records when the token was created.
exp — Expiration
Defines the time after which the token must be rejected.
nbf — Not Before
Optionally prevents acceptance before a specified time.
aud — Audience
Identifies the intended recipient or API.
The current starter does not add or validate an audience claim. Add it when tokens should be restricted to a particular service.
jti — JWT ID
Provides a unique identifier for a token.
The starter does not use jti because access tokens are not persisted or individually revoked. It can be useful for diagnostics or denylist-based designs, though it should not be added without a clear purpose.
roles — Custom Claim
Carries application roles:
{
"roles": ["USER"]
}
The converter maps these to ROLE_USER.
Appendix B — Authentication Status Codes
| Status | Authentication use |
|---|---|
| 200 OK | Successful login or refresh |
| 201 Created | Successful registration |
| 204 No Content | Successful logout with no response body |
| 400 Bad Request | Invalid request shape or validation failure |
| 401 Unauthorized | Missing, invalid, expired, or revoked credentials |
| 403 Forbidden | Authenticated user lacks required authority |
| 409 Conflict | Registration conflicts with an existing email |
| 429 Too Many Requests | Authentication endpoint rate limit exceeded |
| 500 Internal Server Error | Unexpected server or configuration failure |
The historical name “Unauthorized” is slightly misleading: 401 generally means the request is not successfully authenticated. A user who is authenticated but not permitted should receive 403.
Appendix C — Authentication Timeline
08:00 User logs in
├── Access token A1 expires at 08:15
└── Refresh token R1 expires in 30 days
08:10 Client calls protected endpoint with A1
└── Spring Security validates JWT without token lookup
08:15 A1 expires
08:16 Client submits R1
├── Server hashes R1
├── R1 record is marked revoked
├── Access token A2 is created
└── Refresh token R2 is created and hashed
08:17 Reuse of R1
└── 401 Unauthorized
08:30 Client logs out with R2
└── R2 record is marked revoked
08:31 Refresh with R2
└── 401 Unauthorized
Until A2 expires
└── A2 may still authorize requests because access JWTs are stateless
Conclusion
Secure JWT authentication is not about producing a signed string after login.
It is about designing a lifecycle with explicit answers to difficult questions:
- Which credentials authorize ordinary requests?
- Which credentials preserve the session?
- How long does each credential remain valid?
- Which state is stored?
- How is stored state protected?
- What becomes invalid during refresh?
- What does logout revoke?
- How are suspicious retries detected?
- How is the behavior verified against real infrastructure?
- What happens when keys, accounts, roles, or production requirements change?
The architecture in this guide makes a deliberate split.
Access tokens are short-lived JWTs. They are validated statelessly through Spring Security’s resource-server support and are not stored in PostgreSQL.
Refresh tokens are opaque, high-entropy session credentials. Their SHA-256 hashes are stored, their records can be revoked, and each successful refresh rotates the credential.
Passwords are protected with BCrypt. Database structure is managed by Flyway. The full authentication lifecycle is tested through HTTP against PostgreSQL with Testcontainers.
That combination preserves the scalability of stateless API authorization without pretending that long-lived sessions can be managed safely without state.
The implementation is intentionally a foundation rather than the final word for every application. High-risk systems should extend it with audience validation, asymmetric signing, key rotation, atomic refresh consumption, richer session metadata, reuse response, rate limiting, monitoring, and application-specific security review.
But the central principle remains stable:
A production-ready authentication system is not defined by what happens when login succeeds. It is defined by how safely and predictably the system behaves after credentials expire, leak, rotate, fail, and are revoked.
Reference Implementation
The public demo repository contains the architecture overview and usage examples:
https://github.com/teka-it/spring-boot-jwt-starter-demo
The complete reference implementation—including source code, PostgreSQL migrations, Docker Compose, OpenAPI documentation, and integration tests—is available as the Spring Boot JWT Starter Kit:
https://tekait.gumroad.com/l/spring-boot-jwt-starter








Top comments (0)