Refresh Token Implementation in Java
Learn how to implement Refresh Token flow in Java and Spring Boot with JWT access tokens, refresh tokens, database storage, token rotation, logout, expiry handling, and production best practices.
Introduction
In the previous article, we implemented JWT authentication in Spring Boot.
JWT access tokens are usually short-lived.
Example:
Access Token Expiry: 15 minutes
But users should not be forced to log in again every 15 minutes.
That is where refresh tokens come in.
A refresh token is a long-lived token used to generate a new access token when the old access token expires.
In this article, we will implement refresh token flow step by step using Java, Spring Boot, Spring Security, JWT, and database storage.
1. What Problem Does Refresh Token Solve?
If access tokens live too long, stolen tokens remain valid for a long time.
If access tokens expire too quickly, users need to log in frequently.
Refresh token solves this balance.
flowchart TB
Problem["Problem"]
--> ShortAccess["Short Access Token"]
--> BetterSecurity["Better Security"]
Problem --> LongSession["Long User Session"]
--> BetterUX["Better User Experience"]
ShortAccess --> RefreshToken["Refresh Token"]
LongSession --> RefreshToken
2. Access Token vs Refresh Token
| Token Type | Purpose | Expiry | Sent With Every API? |
|---|---|---|---|
| Access Token | Access protected APIs | Short | Yes |
| Refresh Token | Generate new access token | Long | No |
flowchart LR
Client["Client"]
Client --> AccessToken["Access Token<br/>Short lived"]
Client --> RefreshToken["Refresh Token<br/>Long lived"]
AccessToken --> API["Protected APIs"]
RefreshToken --> RefreshAPI["Refresh API"]
3. JWT Login Without Refresh Token
sequenceDiagram
participant User
participant API
participant JWT
User->>API: Login
API->>JWT: Generate Access Token
JWT-->>API: Access Token
API-->>User: Return Access Token
User->>API: Request with Access Token
API-->>User: Response
Note over User,API: When token expires, user must login again
Problem:
Access token expired → user must login again
4. JWT Login With Refresh Token
sequenceDiagram
participant User
participant AuthAPI
participant TokenStore
participant ProtectedAPI
User->>AuthAPI: Login username/password
AuthAPI->>AuthAPI: Generate Access Token
AuthAPI->>TokenStore: Save Refresh Token
AuthAPI-->>User: Access Token + Refresh Token
User->>ProtectedAPI: Request with Access Token
ProtectedAPI-->>User: Protected Response
User->>AuthAPI: Refresh Token Request
AuthAPI->>TokenStore: Validate Refresh Token
AuthAPI-->>User: New Access Token
5. Recommended Token Expiry
Common production setup:
Access Token : 15 minutes
Refresh Token : 7 days / 30 days
Example:
flowchart LR
Login["Login"]
--> Access["Access Token<br/>15 min"]
Login --> Refresh["Refresh Token<br/>7 days"]
Access --> APIs["Protected APIs"]
Refresh --> NewAccess["Generate New Access Token"]
6. Refresh Token Security Rules
A refresh token is powerful.
Treat it carefully.
Best practices:
- Store refresh token in database
- Set expiry date
- Rotate refresh token after use
- Revoke refresh token during logout
- Do not store refresh token in logs
- Use HTTPS
- Store securely on client side
- Detect reused refresh tokens
mindmap
root((Refresh Token Security))
Expiry
Rotation
Database Storage
Revocation
HTTPS
No Logging
Reuse Detection
7. Project Structure
refresh-token-demo
└── src
└── main
└── java
└── com.codewithvenu.refreshtoken
├── config
│ ├── SecurityConfig.java
│ └── JwtAuthenticationFilter.java
├── controller
│ ├── AuthController.java
│ └── DemoController.java
├── dto
│ ├── LoginRequest.java
│ ├── LoginResponse.java
│ ├── RefreshTokenRequest.java
│ └── TokenResponse.java
├── entity
│ ├── AppUser.java
│ └── RefreshToken.java
├── repository
│ ├── AppUserRepository.java
│ └── RefreshTokenRepository.java
├── service
│ ├── AuthService.java
│ ├── JwtService.java
│ ├── RefreshTokenService.java
│ └── CustomUserDetailsService.java
└── RefreshTokenDemoApplication.java
8. Maven Dependencies
<dependencies>
<!-- REST APIs -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- Spring Data JPA -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- Validation -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- H2 for local testing -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<!-- JWT -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.5</version>
<scope>runtime</scope>
</dependency>
</dependencies>
9. application.yml
spring:
datasource:
url: jdbc:h2:mem:refreshdb
driver-class-name: org.h2.Driver
username: sa
password:
h2:
console:
enabled: true
jpa:
hibernate:
ddl-auto: update
show-sql: true
jwt:
secret: "my-super-secret-key-my-super-secret-key-my-super-secret-key"
access-token-expiration: 900000
refresh-token-expiration: 604800000
Explanation
900000 ms = 15 minutes
604800000 ms = 7 days
In production, store secret values in:
- Environment variables
- AWS Secrets Manager
- HashiCorp Vault
- Kubernetes Secrets
10. Entity: AppUser
package com.codewithvenu.refreshtoken.entity;
import jakarta.persistence.*;
@Entity
@Table(name = "app_users")
public class AppUser {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable=false, unique=true)
private String username;
@Column(nullable=false)
private String password;
@Column(nullable=false)
private String role;
protected AppUser() {
}
public AppUser(String username, String password, String role) {
this.username = username;
this.password = password;
this.role = role;
}
public Long getId() {
return id;
}
public String getUsername() {
return username;
}
public String getPassword() {
return password;
}
public String getRole() {
return role;
}
}
11. Entity: RefreshToken
package com.codewithvenu.refreshtoken.entity;
import jakarta.persistence.*;
import java.time.Instant;
@Entity
@Table(name = "refresh_tokens")
public class RefreshToken {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable=false, unique=true, length = 500)
private String token;
@Column(nullable=false)
private Instant expiryDate;
@Column(nullable=false)
private boolean revoked;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable=false)
private AppUser user;
protected RefreshToken() {
}
public RefreshToken(String token, Instant expiryDate, AppUser user) {
this.token = token;
this.expiryDate = expiryDate;
this.user = user;
this.revoked = false;
}
public Long getId() {
return id;
}
public String getToken() {
return token;
}
public Instant getExpiryDate() {
return expiryDate;
}
public boolean isRevoked() {
return revoked;
}
public AppUser getUser() {
return user;
}
public void revoke() {
this.revoked = true;
}
}
Code Explanation
token
Stores the refresh token value.
expiryDate
Defines when token becomes invalid.
revoked
Used during logout or suspicious activity.
ManyToOne
One user can have multiple refresh tokens from multiple devices.
12. Repositories
AppUserRepository
package com.codewithvenu.refreshtoken.repository;
import com.codewithvenu.refreshtoken.entity.AppUser;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface AppUserRepository extends JpaRepository<AppUser, Long> {
Optional<AppUser> findByUsername(String username);
}
RefreshTokenRepository
package com.codewithvenu.refreshtoken.repository;
import com.codewithvenu.refreshtoken.entity.RefreshToken;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
Optional<RefreshToken> findByToken(String token);
void deleteByUserId(Long userId);
}
13. DTOs
LoginRequest
package com.codewithvenu.refreshtoken.dto;
import jakarta.validation.constraints.NotBlank;
public record LoginRequest(
@NotBlank(message = "Username is required")
String username,
@NotBlank(message = "Password is required")
String password
) {
}
LoginResponse
package com.codewithvenu.refreshtoken.dto;
public record LoginResponse(
String accessToken,
String refreshToken
) {
}
RefreshTokenRequest
package com.codewithvenu.refreshtoken.dto;
import jakarta.validation.constraints.NotBlank;
public record RefreshTokenRequest(
@NotBlank(message = "Refresh token is required")
String refreshToken
) {
}
TokenResponse
package com.codewithvenu.refreshtoken.dto;
public record TokenResponse(
String accessToken
) {
}
14. JwtService
package com.codewithvenu.refreshtoken.service;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Service;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
@Service
public class JwtService {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.access-token-expiration}")
private long accessTokenExpiration;
private SecretKey getSigningKey() {
return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
}
public String generateAccessToken(UserDetails userDetails) {
return Jwts.builder()
.subject(userDetails.getUsername())
.claim("roles", userDetails.getAuthorities())
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + accessTokenExpiration))
.signWith(getSigningKey())
.compact();
}
public String extractUsername(String token) {
return extractAllClaims(token).getSubject();
}
public boolean isTokenValid(String token, UserDetails userDetails) {
String username = extractUsername(token);
return username.equals(userDetails.getUsername())
&& !isTokenExpired(token);
}
private boolean isTokenExpired(String token) {
return extractAllClaims(token)
.getExpiration()
.before(new Date());
}
private Claims extractAllClaims(String token) {
return Jwts.parser()
.verifyWith(getSigningKey())
.build()
.parseSignedClaims(token)
.getPayload();
}
}
15. RefreshTokenService
package com.codewithvenu.refreshtoken.service;
import com.codewithvenu.refreshtoken.entity.AppUser;
import com.codewithvenu.refreshtoken.entity.RefreshToken;
import com.codewithvenu.refreshtoken.repository.RefreshTokenRepository;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.security.SecureRandom;
import java.time.Instant;
import java.util.Base64;
@Service
public class RefreshTokenService {
private final RefreshTokenRepository repository;
private final SecureRandom secureRandom = new SecureRandom();
@Value("${jwt.refresh-token-expiration}")
private long refreshTokenExpiration;
public RefreshTokenService(RefreshTokenRepository repository) {
this.repository = repository;
}
public RefreshToken createRefreshToken(AppUser user) {
String token = generateSecureToken();
Instant expiryDate = Instant.now()
.plusMillis(refreshTokenExpiration);
RefreshToken refreshToken = new RefreshToken(
token,
expiryDate,
user
);
return repository.save(refreshToken);
}
public RefreshToken validateRefreshToken(String token) {
RefreshToken refreshToken = repository.findByToken(token)
.orElseThrow(() -> new RuntimeException("Invalid refresh token"));
if (refreshToken.isRevoked()) {
throw new RuntimeException("Refresh token revoked");
}
if (refreshToken.getExpiryDate().isBefore(Instant.now())) {
throw new RuntimeException("Refresh token expired");
}
return refreshToken;
}
public void revokeRefreshToken(String token) {
RefreshToken refreshToken = repository.findByToken(token)
.orElseThrow(() -> new RuntimeException("Invalid refresh token"));
refreshToken.revoke();
repository.save(refreshToken);
}
private String generateSecureToken() {
byte[] randomBytes = new byte[64];
secureRandom.nextBytes(randomBytes);
return Base64.getUrlEncoder()
.withoutPadding()
.encodeToString(randomBytes);
}
}
Code Explanation
SecureRandom
Generates cryptographically strong random values.
Base64.getUrlEncoder()
Creates a URL-safe token.
validateRefreshToken
Checks:
- Token exists
- Token is not revoked
- Token is not expired
revokeRefreshToken
Marks token as revoked during logout.
16. CustomUserDetailsService
package com.codewithvenu.refreshtoken.service;
import com.codewithvenu.refreshtoken.entity.AppUser;
import com.codewithvenu.refreshtoken.repository.AppUserRepository;
import org.springframework.security.core.userdetails.*;
import org.springframework.stereotype.Service;
@Service
public class CustomUserDetailsService implements UserDetailsService {
private final AppUserRepository repository;
public CustomUserDetailsService(AppUserRepository repository) {
this.repository = repository;
}
@Override
public UserDetails loadUserByUsername(String username) {
AppUser user = repository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
return User.withUsername(user.getUsername())
.password(user.getPassword())
.roles(user.getRole())
.build();
}
}
17. AuthService
package com.codewithvenu.refreshtoken.service;
import com.codewithvenu.refreshtoken.dto.*;
import com.codewithvenu.refreshtoken.entity.AppUser;
import com.codewithvenu.refreshtoken.entity.RefreshToken;
import com.codewithvenu.refreshtoken.repository.AppUserRepository;
import org.springframework.security.authentication.*;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
@Service
public class AuthService {
private final AuthenticationManager authenticationManager;
private final AppUserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final JwtService jwtService;
private final RefreshTokenService refreshTokenService;
private final CustomUserDetailsService userDetailsService;
public AuthService(AuthenticationManager authenticationManager,
AppUserRepository userRepository,
PasswordEncoder passwordEncoder,
JwtService jwtService,
RefreshTokenService refreshTokenService,
CustomUserDetailsService userDetailsService) {
this.authenticationManager = authenticationManager;
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
this.jwtService = jwtService;
this.refreshTokenService = refreshTokenService;
this.userDetailsService = userDetailsService;
}
public String register(LoginRequest request) {
boolean exists = userRepository.findByUsername(request.username()).isPresent();
if (exists) {
return "Username already exists";
}
AppUser user = new AppUser(
request.username(),
passwordEncoder.encode(request.password()),
"USER"
);
userRepository.save(user);
return "User registered successfully";
}
public LoginResponse login(LoginRequest request) {
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.username(),
request.password()
)
);
AppUser user = userRepository.findByUsername(request.username())
.orElseThrow();
UserDetails userDetails = userDetailsService
.loadUserByUsername(user.getUsername());
String accessToken = jwtService.generateAccessToken(userDetails);
RefreshToken refreshToken = refreshTokenService
.createRefreshToken(user);
return new LoginResponse(
accessToken,
refreshToken.getToken()
);
}
public TokenResponse refresh(RefreshTokenRequest request) {
RefreshToken refreshToken = refreshTokenService
.validateRefreshToken(request.refreshToken());
AppUser user = refreshToken.getUser();
UserDetails userDetails = userDetailsService
.loadUserByUsername(user.getUsername());
String newAccessToken = jwtService.generateAccessToken(userDetails);
return new TokenResponse(newAccessToken);
}
public String logout(RefreshTokenRequest request) {
refreshTokenService.revokeRefreshToken(request.refreshToken());
return "Logged out successfully";
}
}
18. AuthController
package com.codewithvenu.refreshtoken.controller;
import com.codewithvenu.refreshtoken.dto.*;
import com.codewithvenu.refreshtoken.service.AuthService;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final AuthService service;
public AuthController(AuthService service) {
this.service = service;
}
@PostMapping("/register")
public String register(@Valid @RequestBody LoginRequest request) {
return service.register(request);
}
@PostMapping("/login")
public LoginResponse login(@Valid @RequestBody LoginRequest request) {
return service.login(request);
}
@PostMapping("/refresh")
public TokenResponse refresh(@Valid @RequestBody RefreshTokenRequest request) {
return service.refresh(request);
}
@PostMapping("/logout")
public String logout(@Valid @RequestBody RefreshTokenRequest request) {
return service.logout(request);
}
}
19. Refresh Token API Flow
sequenceDiagram
participant Client
participant AuthController
participant AuthService
participant RefreshTokenService
participant JwtService
Client->>AuthController: POST /api/auth/refresh
AuthController->>AuthService: refresh(request)
AuthService->>RefreshTokenService: validateRefreshToken()
RefreshTokenService-->>AuthService: valid token
AuthService->>JwtService: generateAccessToken()
JwtService-->>AuthService: new access token
AuthService-->>Client: access token
20. SecurityConfig
package com.codewithvenu.refreshtoken.config;
import org.springframework.context.annotation.*;
import org.springframework.security.authentication.*;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.*;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http)
throws Exception {
http
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/h2-console/**").permitAll()
.anyRequest().authenticated()
)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
);
http.headers(headers -> headers.frameOptions(frame -> frame.disable()));
return http.build();
}
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration config)
throws Exception {
return config.getAuthenticationManager();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
21. Test Registration
curl -X POST http://localhost:8080/api/auth/register \
-H "Content-Type: application/json" \
-d '{
"username": "venu",
"password": "password123"
}'
Expected:
User registered successfully
22. Test Login
curl -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{
"username": "venu",
"password": "password123"
}'
Expected:
{
"accessToken": "eyJhbGciOiJIUzI1NiJ9...",
"refreshToken": "UzZkX..."
}
23. Test Refresh Token
curl -X POST http://localhost:8080/api/auth/refresh \
-H "Content-Type: application/json" \
-d '{
"refreshToken": "YOUR_REFRESH_TOKEN"
}'
Expected:
{
"accessToken": "new-access-token"
}
24. Test Logout
curl -X POST http://localhost:8080/api/auth/logout \
-H "Content-Type: application/json" \
-d '{
"refreshToken": "YOUR_REFRESH_TOKEN"
}'
Expected:
Logged out successfully
If you try the same refresh token again:
Refresh token revoked
25. Refresh Token Rotation
A more secure implementation rotates refresh tokens.
Instead of returning only a new access token:
Refresh token request
↓
Validate old refresh token
↓
Revoke old refresh token
↓
Generate new access token
↓
Generate new refresh token
flowchart TB
OldRefresh["Old Refresh Token"]
--> Validate["Validate"]
Validate --> Revoke["Revoke Old Token"]
Revoke --> NewAccess["Generate New Access Token"]
Revoke --> NewRefresh["Generate New Refresh Token"]
This reduces risk if a refresh token is stolen.
26. Access Token + Refresh Token Architecture
flowchart TB
Client["Frontend / Mobile App"]
Client --> LoginAPI["Login API"]
LoginAPI --> AccessToken["Access Token"]
LoginAPI --> RefreshToken["Refresh Token"]
AccessToken --> ProtectedAPI["Protected APIs"]
RefreshToken --> RefreshAPI["Refresh API"]
RefreshAPI --> NewAccessToken["New Access Token"]
27. Production Best Practices
- Keep access tokens short-lived
- Store refresh tokens in database
- Revoke refresh tokens on logout
- Rotate refresh tokens after use
- Use HTTPS only
- Do not log tokens
- Do not store sensitive data inside JWT
- Store refresh token securely on client
- Detect refresh token reuse
- Use device/session tracking
- Clean expired tokens periodically
28. Common Mistakes
| Mistake | Risk |
|---|---|
| Long-lived access token | Stolen token valid too long |
| No refresh token expiry | Unlimited access risk |
| Refresh token not stored | Cannot revoke |
| Refresh token not rotated | Higher replay risk |
| Tokens logged | Token leakage |
| No HTTPS | Token interception |
| Same token forever | Poor session security |
29. Interview Questions
Q1. What is a refresh token?
A refresh token is a long-lived token used to generate new access tokens.
Q2. Why not use long-lived access tokens?
Because if stolen, they allow direct API access for a long time.
Q3. Where should refresh tokens be stored?
They should be stored securely, commonly in a database on the server side.
Q4. Should refresh tokens be JWT or random strings?
They can be either, but opaque random tokens stored in DB are easier to revoke.
Q5. What is token rotation?
Token rotation means issuing a new refresh token and revoking the old one whenever refresh is used.
Q6. What happens during logout?
The refresh token should be revoked or deleted.
Q7. Why should refresh tokens have expiry?
To prevent unlimited long-term access.
30. Key Takeaways
- Access tokens should be short-lived.
- Refresh tokens improve user experience.
- Refresh tokens must be stored and protected.
- Refresh token expiry is mandatory.
- Logout should revoke refresh tokens.
- Token rotation improves security.
- Never log access or refresh tokens.
- Always use HTTPS in production.
Next Article
➡️ OAuth2 Login with Google and GitHub