Users Hated Being Logged Out: Mastering JWT Token Expiration
0. "Your Account Has Been Compromised" (Pre-reading)
Imagine you used public Wi-Fi at a cafe. You went to the bathroom, and someone stole your session key.
You changed your password, but the hacker is still shopping with your account.
This is the horror of Token Hijacking.
JWT is stateless, meaning the server cannot forcefully revoke it once issued.
That's why Expiration and Refresh Strategy are the heart of security.
1. "Boss, I Lost Everything I Wrote!"
One week after launching my service, I received a furious email from a customer.
They had spent an hour writing a heartfelt post, but when they clicked 'Save', they were suddenly kicked to the login screen, and all their content vanished.
Checking the logs, the culprit was "Access Token Expiration".
In my zeal for security, I had set the token validity to 30 minutes.
Since the user took 31 minutes to write, the token was already dead by the time the request hit the server.
"Ah, in trying to secure the app, I'm driving all users away."
This incident became my wake-up call. I set a new goal: "Users must never know they were logged out and re-logged in." I completely overhauled my JWT strategy.
2. The Dilemma: Security vs. Convenience
The most confusing part of learning JWT was the relationship between Access Token and Refresh Token.
At first, I thought, "Why not just make the Access Token valid for 30 days?"
But that is like "Losing your house key".
If a hacker steals a 30-day token, even if I change my password, the hacker can impersonate me for 30 days. (Because standard JWTs cannot be forcefully revoked by the server efficiently!)
So we need a Dual Key Strategy.
- Access Token (Convenience Store Badge): Valid for 15 minutes. Even if stolen, it becomes useless quickly.
- Refresh Token (Safe Key): Valid for 14 days. Stored in a very secure place (httpOnly Cookie).
The core concept is: "When the badge expires, use the safe key to secretly get a new badge." All without the user noticing.
2.5. The Secret of 'Remember Me'
What does that "Remember Me" checkbox actually do?
It decides the lifespan of the Refresh Token.
- Unchecked: Refresh Token is a Session Cookie. Deleted when browser closes.
- Checked: Refresh Token is a Persistent Cookie (e.g., 14 days). Survives browser restart.
Even a simple checkbox hides such technical details.
3. Implementation: The Art of Deception (Axios Interceptor)
The moment a user thinks, "Oh? Am I logged out?" you have failed.
You must swap the token quietly in the background.
I used Axios Interceptor to automate this process.
It's like a bank teller saying, "One moment, let me verify your ID again," and photocopying your ID in the back room while you wait.
// axios.ts
api.interceptors.response.use(
(response) => response, // If successful, just pass
async (error) => {
const originalRequest = error.config;
// "Huh? Token expired(401)?" && "Haven't retried yet?"
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true; // Flag to prevent infinite loop
try {
// 1. Beg for a new Access Token using Refresh Token
const { data } = await axios.post('/api/auth/refresh');
// 2. Swap the new token
localStorage.setItem('accessToken', data.accessToken);
originalRequest.headers.Authorization = `Bearer ${data.accessToken}`;
// 3. Retry the failed request (Seamlessly!)
return api(originalRequest);
} catch (refreshError) {
// If Refresh Token is also expired... then it's a real goodbye (Logout)
window.location.href = '/login';
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
}
);
With this code, users can continue using the service uninterrupted, regardless of token expiration.
4. The Storage War: LocalStorage vs Cookie
Where to store tokens is an eternal debate among developers.
I initially thought, "Of course LocalStorage, right?" until I read security docs and freaked out.
The Fatal Weakness of LocalStorage: XSS
If a hacker plants a malicious script (alert(localStorage.getItem('token'))) on my site, the token is stolen instantly.
If the Access Token is stolen, it's only dangerous for 15 minutes. But if the Refresh Token is stolen, your account is hijacked permanently.
Conclusion: Hybrid Strategy
So I compromised.
- Access Token → Store in LocalStorage. (Easy to use in JS)
- Refresh Token → Store in httpOnly Cookie. (Inaccessible via JS, Hacker can't steal it)
This way, even if an XSS attack happens, they can only get the Access Token, and the Refresh Token remains safe.
4.1. The Price of Cookies: CSRF Attacks
Using cookies protects against XSS, but invites a new enemy: CSRF (Cross-Site Request Forgery).
A hacker can create a fake site that sends a "Transfer Money" request to your bank.
Because browsers automatically attach cookies, the bank thinks it's you.
Don't worry. The SameSite attribute saves us.
SameSite=Strict: Cookies are sent ONLY from the same domain. (Safest)
SameSite=Lax: Allowed when following a link, but blocked otherwise. (Good for login sessions)
So, httpOnly; Secure; SameSite=Lax is the holy trinity of cookie settings.
4.2. Security Summary (XSS vs CSRF)
| Attack Type | Description | Defense Strategy |
|---|
| XSS | Malicious JS Execution | httpOnly Cookie (No JS Access) |
| CSRF | Fake Request Forgery | SameSite=Strict/Lax Cookie |
4.5. Advanced Security: Refresh Token Rotation
Storing the Refresh Token in an httpOnly cookie is good, but what if knowing the cookie is enough?
If a hacker manages to steal that cookie (e.g., via a browser vulnerability or malware), they can keep refreshing the Access Token forever.
To prevent this nightmare, I implemented Refresh Token Rotation.
The concept is simple: "The Refresh Token is also one-time use only."
- Client sends
RefreshToken (A) to get a new Access Token.
- Server verifies
A, issues Access Token (B) AND a new RefreshToken (C).
- Server deletes
RefreshToken (A) from the database.
- Client replaces
A with C.
Detection of Theft
If a hacker tries to use the old RefreshToken (A) after the legitimate user has already rotated it to C:
- Server checks DB: "Wait,
A was already used!"
- Security Alert! It means someone else has a copy of
A.
- Server immediately invalidates ALL tokens (A, C, and everything else) belonging to that user family.
- Both the hacker and the real user are forced to log out.
This turns a "Permanent Account Takeover" into a "One-time inconvenience".
5. Concurrency Issue: "Hey, Get in Line"
The final boss I faced was "Duplicate Renewals".
When loading the dashboard, the app calls 5 APIs simultaneously.
What if the token is expired?
- API 1 → 401 Error → Request Refresh
- API 2 → 401 Error → Request Refresh
- API 3 → 401 Error → Request Refresh...
Instantly, 5 refresh requests fly out. The server goes, "Wait, I just gave you a new one! Why again?" and throws an error (especially if using Refresh Token Rotation), causing a logout.
The Solution: Queueing
We need a "Traffic Control" system.
If the first request has gone to refresh, the other requests must be put in a Queue and told to wait.
sequenceDiagram
participant App
participant Interceptor
participant Server
App->>Interceptor: API Request 1 (Token Expired)
App->>Interceptor: API Request 2 (Token Expired)
Interceptor->>Server: Refresh Token Request (For Req 1)
Note over Interceptor: Req 2 is queued! ⏳
Server-->>Interceptor: New Access Token
Interceptor->>App: Retry Req 1 (Success)
Interceptor->>App: Retry Req 2 (Success)
Here is a more robust implementation of the queue system:
let isRefreshing = false;
let failedQueue: ((token: string) => void)[] = [];
const processQueue = (error: any, token: string | null = null) => {
failedQueue.forEach((prom) => {
if (error) {
prom(Promise.reject(error));
} else {
prom(token);
}
});
failedQueue = [];
};
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push((token) => {
if (token instanceof Error) {
reject(token);
} else {
originalRequest.headers.Authorization = `Bearer ${token}`;
resolve(api(originalRequest));
}
});
});
}
originalRequest._retry = true;
isRefreshing = true;
try {
const { data } = await axios.post('/api/auth/refresh');
const newToken = data.accessToken;
localStorage.setItem('accessToken', newToken);
api.defaults.headers.common['Authorization'] = `Bearer ${newToken}`;
processQueue(null, newToken); // Execute everyone in the queue!
return api(originalRequest);
} catch (refreshError) {
processQueue(refreshError, null); // Fail everyone in the queue
window.location.href = '/login';
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
return Promise.reject(error);
}
);
This ensures only ONE refresh request happens, and all pending requests use the new token once it arrives.
It also handles failures gracefully—if the refresh fails, everyone in the queue gets rejected.
6. What About Next.js Server Components?
With the new Next.js App Router, things get tricker because Server Components cannot access LocalStorage.
You are forced to rely on Cookies.
This introduced a new pattern:
- Middleware checks if the
refreshToken cookie exists.
- If
accessToken is expired but refreshToken is valid, the Middleware refreshes it before rendering the page.
- Server Components just assume the token is valid.
sequenceDiagram
participant Browser
participant Middleware
participant ServerComponent
participant AuthServer
Browser->>Middleware: Request /dashboard (Cookie: RefreshToken)
Middleware->>Middleware: Check AccessToken (Expired?)
alt AccessToken Expired
Middleware->>AuthServer: Refresh Request
AuthServer-->>Middleware: New AccessToken
Middleware->>Middleware: Set-Cookie: New AccessToken
end
Middleware->>ServerComponent: Forward Request (Header: Authorization)
ServerComponent->>ServerComponent: Render Data
ServerComponent-->>Browser: HTML Response
Implementing Token Refresh in Middleware
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export async function middleware(request: NextRequest) {
const accessToken = request.cookies.get('accessToken');
const refreshToken = request.cookies.get('refreshToken');
if (!accessToken && refreshToken) {
// 1. Refresh on the server side
const newTokens = await fetch('https://api.myapp.com/refresh', {
method: 'POST',
headers: { Cookie: `refreshToken=${refreshToken.value}` }
});
if (newTokens.ok) {
const data = await newTokens.json();
const response = NextResponse.next();
// 2. Update Cookie for the Client
response.cookies.set('accessToken', data.accessToken);
return response;
}
}
return NextResponse.next();
}
This moves the complexity from the Client (Axios) to the Server (Middleware), but the principle remains the same: Hide the complexity from the user.
7. Conclusion: Security Should Be Invisible
"It's unavoidable because of security" is a lazy developer's excuse.
True security must be maintained without inconveniencing the user.
Now, users of my service can write a post, go to the bathroom, eat lunch, come back, and hit save without being logged out.
But behind the scenes, my code is fiercely checking and renewing tokens every 15 minutes.
Just like a swan paddling frantically beneath the water.
How is your service? Are you still annoying your users with "Session Expired" modals?
Stop checking your logs for complaints and start implementing silent refreshes today.
🎁 Appendix: What lies inside the Payload?
Some developers stuff email, address, and even phoneNumber into the token.
Please don't.
JWT is Base64 encoded, not encrypted. Anyone can decode it on jwt.io.
- Safe:
userId, role, exp
- Unsafe:
password, phoneNumber, address (PII)