Implementing Access and Refresh Tokens Together in Node.js
Short-lived access tokens plus rotating refresh tokens in Node.js: the JWT flow, where to store each token, and how rotation catches stolen refresh tokens.
This article is published in English.
Sources: Access Tokens vs Refresh Tokens in Node.js: Why Real Applications Need Both
When you build your first authentication flow in Node.js, the typical approach is to rely on a single JSON Web Token (JWT). The user submits an email and password, the server checks them, signs a token, and hands it back. From that point forward, the frontend includes this token inside the Authorization header on every request.
This setup feels fine while you're testing on localhost. But as soon as real users start relying on the app, a structural tension shows up.
Set the token's lifetime to something short, like 15 minutes, and users get booted out mid-task—maybe while typing a comment or halfway through a form. Set it to something long, like 30 days, and you keep people logged in, but you also open a serious security gap: if that token gets stolen, there's no way to revoke it without invalidating every token for every user across your entire system.
This tension between convenience for users and security is exactly why production-grade systems don't rely on one token—they use two: an access token and a refresh token.
Let's look at why this two-token model exists, what role each token plays, and how to build it correctly in a Node.js backend.
The Core Problem with Single-Token Authentication
Before explaining why two tokens are necessary, it helps to revisit what makes JWTs unique in the first place: they're stateless.
When your Node.js server receives a JWT, it doesn't need to hit a database to verify a session exists. It just checks the token's cryptographic signature against a secret key:
Token payload + Secret key = Signature verified?
├── Yes -> Request allowed
└── No -> 401 Unauthorized
Statelessness is what makes JWT-based APIs fast and easy to scale, since your database isn't queried on every single incoming request. But this same property has a downside: once a token is issued, the server has no easy way to invalidate it early.
Consider this chain of events:
- Your single JWT is configured to last 30 days.
- An attacker gets hold of it—maybe through an XSS attack or by intercepting traffic on an unsecured network.
- The account owner notices something's wrong and changes their password.
- The attacker keeps working as if nothing happened. The stolen token stays valid on paper until its 30-day lifespan runs out, regardless of the password change.
One workaround is maintaining a blacklist of revoked tokens in something like Redis, but checking that list on every API call effectively turns your "stateless" token system back into a stateful one.
This is the exact problem the two-token approach is designed to fix.
What Are Access Tokens and Refresh Tokens?
Rather than making a single token responsible for both routine API access and long-term login persistence, the workload gets split between two tokens:
+------------------+-----------------------------+-----------------------------+
| Feature | Access Token | Refresh Token |
+------------------+-----------------------------+-----------------------------+
| Purpose | Authenticate API requests | Obtain new access tokens |
| Lifetime | Short (5 to 15 minutes) | Long (7 to 30 days) |
| Storage | Memory or short-lived state | HttpOnly, Secure Cookie |
| Database lookup | No (purely stateless) | Yes (checked on refresh) |
| Exposure risk | High (sent on every request)| Low (sent only to /refresh) |
+------------------+-----------------------------+-----------------------------+
1. The Access Token
Think of the access token as a short-lived pass. The client attaches it to the headers of ordinary API requests—things like GET /api/orders or POST /api/profile.
Since it's only valid for a brief window (typically 10 to 15 minutes), any potential damage from a leak is limited. The server can verify it purely through CPU-based signature checks, without touching the database, which keeps the API responsive.
2. The Refresh Token
The refresh token works like a long-term renewal voucher. It's never sent along with regular business API calls—its only job is to talk to a dedicated endpoint, something like POST /api/auth/refresh.
Once the short-lived access token expires, the client quietly calls this refresh route. The server then queries the database to confirm the user's account is still active and authorized, then issues a fresh access token.
Because this refresh call only happens roughly every 15 minutes rather than on every user interaction, adding a database lookup here barely affects performance.
The Request Lifecycle
Here's how the three parties—client, server, and database—interact over time:
Client Server Database
│ │ │
├─────── 1. Login (credentials) ──>│ │
│ ├─── Verify credentials ─────────>│
│ │<── User record valid ───────────┤
│ ├─── Store Refresh Token Hash ───>│
│<────── 2. Return Tokens ─────────┤ │
│ (Access: 15m, Refresh: 7d) │ │
│ │ │
│─────── 3. GET /api/dashboard ───>│ │
│ [Bearer Access Token] ├─── Verify signature (Stateless) │
│<────── 4. 200 OK + Data ─────────┤ │
│ │ │
│ ... 15 minutes pass ... │ │
│ │ │
│─────── 5. GET /api/dashboard ───>│ │
│ [Expired Access Token] ├─── Token expired! │
│<────── 6. 401 Unauthorized ──────┤ │
│ │ │
│─────── 7. POST /api/auth/refresh>│ │
│ [Cookie: Refresh Token] ├─── Check if token in DB ───────>│
│ │<── Token valid & not revoked ───┤
│ ├─── Generate new Access Token │
│<────── 8. Return new Access Token┤ │
│ │ │
│─────── 9. Retry GET /dashboard ─>│ │
│ [New Access Token] ├─── Verify signature │
│<────── 10. 200 OK + Data ────────┤ │
From the user's point of view, none of this renewal process is visible. Their session just keeps working in the background, with no jarring "Session Expired" prompts interrupting active use.
Putting It Into Practice with Node.js
Now let's build a working Express setup that shows how to issue, check, and renew tokens.
Start by installing the required packages:
npm install express jsonwebtoken cookie-parser dotenv
1. Creating the Tokens
Set up a helper module that produces both tokens, each signed with its own secret:
// tokenService.js
const jwt = require('jsonwebtoken');
const ACCESS_TOKEN_SECRET = process.env.ACCESS_TOKEN_SECRET || 'access_secret_123';
const REFRESH_TOKEN_SECRET = process.env.REFRESH_TOKEN_SECRET || 'refresh_secret_456';
function generateAccessToken(userId) {
// Short lifespan: 15 minutes
return jwt.sign({ sub: userId }, ACCESS_TOKEN_SECRET, { expiresIn: '15m' });
}
function generateRefreshToken(userId) {
// Long lifespan: 7 days
return jwt.sign({ sub: userId }, REFRESH_TOKEN_SECRET, { expiresIn: '7d' });
}
module.exports = {
generateAccessToken,
generateRefreshToken,
ACCESS_TOKEN_SECRET,
REFRESH_TOKEN_SECRET
};
Key point: always give the access token and the refresh token separate secret keys. If they shared one secret, someone could take an expired refresh token and present it where an access token is expected, sidestepping the expiration you intended to enforce.
2. Handling Login and Saving the Refresh Token
The login route needs to store the refresh token—or better, its SHA-256 hash—in your database, tied to that user, so you can look it up later:
// server.js
const express = require('express');
const cookieParser = require('cookie-parser');
const jwt = require('jsonwebtoken');
const {
generateAccessToken,
generateRefreshToken,
ACCESS_TOKEN_SECRET,
REFRESH_TOKEN_SECRET
} = require('./tokenService');
const app = express();
app.use(express.json());
app.use(cookieParser());
// Simulated database table for refresh tokens
const refreshTokenDatabase = new Set();
app.post('/api/auth/login', async (req, res) => {
const { email, password } = req.body;
// In a real app: verify email and hashed password from DB
const mockUserId = 'usr_987654';
const accessToken = generateAccessToken(mockUserId);
const refreshToken = generateRefreshToken(mockUserId);
// Save the refresh token to our database so we can revoke it later
refreshTokenDatabase.add(refreshToken);
// Send refresh token inside an HttpOnly cookie
res.cookie('refreshToken', refreshToken, {
httpOnly: true, // Prevents JavaScript from reading the cookie (protects from XSS)
secure: process.env.NODE_ENV === 'production', // Requires HTTPS
sameSite: 'strict', // Protects against CSRF attacks
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
});
// Send access token in JSON body for frontend memory storage
res.json({ accessToken });
});
3. Guarding Business Endpoints
A middleware function checks the access token pulled from the Authorization header before letting a request through:
function authenticateAccessToken(req, res, next) {
const authHeader = req.headers['authorization'];
// The header format is "Bearer <token>"
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'Access token missing' });
}
jwt.verify(token, ACCESS_TOKEN_SECRET, (err, decoded) => {
if (err) {
// 403 signals to the client that the token is invalid or expired
return res.status(403).json({ error: 'Invalid or expired access token' });
}
req.userId = decoded.sub;
next();
});
}
// Protected route
app.get('/api/orders', authenticateAccessToken, (req, res) => {
res.json({
message: `Orders retrieved for user ${req.userId}`,
data: [{ id: 1, item: 'Mechanical Keyboard' }]
});
});
4. Building the Refresh Route
Once the client gets a 403 from a call like /api/orders, it hits this endpoint to obtain a new access token:
app.post('/api/auth/refresh', (req, res) => {
const refreshToken = req.cookies.refreshToken;
if (!refreshToken) {
return res.status(401).json({ error: 'Refresh token missing' });
}
// Check if token exists in the database
if (!refreshTokenDatabase.has(refreshToken)) {
return res.status(403).json({ error: 'Refresh token revoked or unknown' });
}
jwt.verify(refreshToken, REFRESH_TOKEN_SECRET, (err, decoded) => {
if (err) {
// Remove invalid token from database
refreshTokenDatabase.delete(refreshToken);
return res.status(403).json({ error: 'Refresh token expired' });
}
// Token is valid! Issue a new access token
const newAccessToken = generateAccessToken(decoded.sub);
res.json({ accessToken: newAccessToken });
});
});
5. Signing Out and Revoking Access
Signing a user out is simple: remove the refresh token from your database and clear the cookie holding it. The access token doesn't need any special handling—it will simply expire on its own within a few minutes.
app.post('/api/auth/logout', (req, res) => {
const refreshToken = req.cookies.refreshToken;
if (refreshToken) {
refreshTokenDatabase.delete(refreshToken);
}
res.clearCookie('refreshToken');
res.json({ message: 'Logged out successfully' });
});
Where Should Tokens Live on the Client?
A question that comes up constantly is where the frontend should keep these tokens.
A frequent misstep is stashing both tokens inside localStorage.
The problem is that any script running in your page—whether it came from a compromised npm dependency or an injected inline tag—can read localStorage with nothing more than localStorage.getItem('token').
A safer pattern looks like this:
- Keep the access token in memory only. Store it in your app's runtime state—React Context, a Pinia store, or even a plain variable. Since it expires quickly and vanishes the moment the tab closes, even a successful script injection only exposes a narrow window of risk.
- Keep the refresh token in an
HttpOnlycookie. JavaScript has no access toHttpOnlycookies at all. The browser attaches it automatically whenever the client calls/api/auth/refresh, which means your long-lived credential never touches code that an attacker might control.
Strengthening Security with Refresh Token Rotation
Even with the setup above, a plain refresh token scheme has a lingering weakness: if an attacker manages to grab the refresh token—whether from cookie storage or by sniffing traffic—they could keep minting fresh access tokens for as long as that refresh token remains valid, potentially the full 7-day window.
Production systems close this gap with refresh token rotation.
Under rotation:
- Each time a refresh token is redeemed for a new access token, the server kills the old refresh token and hands back a brand-new one.
- The access and refresh tokens are replaced together as a pair, request after request.
Request 1: Send Refresh Token A -> Receive Access Token 2 + Refresh Token B (Token A invalidated)
Request 2: Send Refresh Token B -> Receive Access Token 3 + Refresh Token C (Token B invalidated)
Catching Breaches Automatically
Rotating refresh tokens unlocks a powerful safeguard called reuse detection.
Picture a scenario where an attacker manages to copy Refresh Token A.
- The genuine user redeems
Refresh Token Afirst, and the server responds withRefresh Token B. - Some time later, the attacker tries to redeem the now-stale
Refresh Token A. - The server checks its records, sees that
Refresh Token Awas already consumed, and flags this as a sign of compromise. - It responds by revoking the whole chain of tokens—including
Refresh Token B—which forces both the attacker and the legitimate user back to the login screen.
Instead of an attacker quietly persisting in the background, the system reacts immediately and locks everyone out until re-authentication happens.
Pitfalls Worth Avoiding
Watch out for these four recurring mistakes:
- Embedding sensitive information in the access token. A JWT payload is only Base64-encoded, not encrypted, so anyone who intercepts it can read the contents directly. Keep passwords, card numbers, and other private details out of the payload entirely.
- Letting refresh token records pile up forever. Without an expiration column and a cleanup routine, your refresh-token table will grow without bound. Add an
expires_atfield and schedule a job that periodically deletes expired entries. - Overlooking race conditions on the client. When several API calls fire at once and the access token has already expired, they'll all hit
403simultaneously. If your frontend then fires off several parallel/refreshcalls in response, rotation logic will interpret this as reuse and terminate the session. Guard against this by queuing outbound requests behind a single in-flight refresh call.
Situations Where One Token Might Be Enough
This dual-token approach is the standard choice for modern web and mobile clients, but it isn't mandatory everywhere:
- Internal service-to-service traffic: machines talking to machines typically rely on client credential flows, short-lived tokens, or mutual TLS, none of which need an interactive refresh cycle.
- Basic server-rendered apps: for a traditional server-side-rendered application—say, one built with classic Express templates—a simple session cookie backed by an in-memory store such as Redis is usually simpler to manage and offers comparable security.
Reach for the access-and-refresh-token pattern specifically when you're building single-page applications (React, Vue, or mobile clients) that talk to stateless REST or GraphQL backends.
Wrapping Up
Relying on one JWT for everything forces a tradeoff you shouldn't have to make: either keep sessions short and annoy users with frequent logins, or keep them long and expose yourself to serious security risk.
Splitting authentication into two tokens resolves that tension:
- Access tokens keep every request fast and stateless, sparing your database from constant lookups.
- Refresh tokens manage long-term session persistence safely, giving you the ability to revoke compromised sessions instantly.
Investing the effort to wire up both tokens correctly rewards you with a smooth experience for users and a system that stands up well against the security issues covered here.