Full Stack • Java • System Design • Cloud • AI Engineering

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