1. Introduction & The Problem
As applications grow from monolithic structures to distributed microservice architectures, authentication becomes a critical bottleneck. Traditional session-based authentication, relying on server-side state, introduces significant challenges: scalability issues due to sticky sessions, cross-origin resource sharing (CORS) complexities, and difficulty in maintaining state across multiple independent services. Imagine a scenario where a user logs in, and their session needs to be validated across a dozen different microservices, each potentially running on different servers or even different cloud providers. The overhead of replicating sessions or routing requests to specific servers to maintain session affinity quickly becomes unsustainable, leading to increased latency, operational complexity, and potential points of failure.
While JSON Web Tokens (JWTs) offer a stateless solution by encoding user information directly into the token, a significant vulnerability remains: if a JWT (access token) is compromised, it remains valid until its expiration. For long-lived access tokens, this poses a substantial security risk, as a malicious actor could use the token for an extended period. Making JWTs short-lived mitigates this risk but forces users to re-authenticate frequently, degrading the user experience. This dilemma—balancing security with user convenience and scalability—is a common pain point for engineering teams building modern, distributed systems.
2. The Solution Concept & Architecture
The robust solution lies in combining short-lived JWTs (access tokens) with long-lived refresh tokens. This dual-token approach offers the best of both worlds: the statelessness and scalability of JWTs, coupled with a mechanism for secure re-authentication and token revocation. Here’s how it works:
- Access Token (JWT): This token is short-lived (e.g., 5-15 minutes), stateless, and contains just enough information to identify the user and their permissions. It's sent with every request to protected microservices. If compromised, its limited lifespan reduces the window of attack.
- Refresh Token: This token is long-lived (e.g., days, weeks, or months), often stored securely on the client (e.g., HTTP-only cookie) and in a database on the authentication service. Its sole purpose is to request new access tokens once the current one expires. Crucially, refresh tokens can be revoked by the server, providing a mechanism for logging users out or invalidating compromised sessions.
Architectural Flow:
- User Login: User sends credentials to an dedicated Authentication Service.
- Token Generation: The Authentication Service verifies credentials, then generates a short-lived access token and a long-lived refresh token.
- Token Storage: The access token is returned to the client (e.g., in memory or local storage for client-side JavaScript, or an Authorization header). The refresh token is sent via an HTTP-only, secure cookie, and also stored securely (hashed) in the Authentication Service's database, linked to the user.
- Resource Access: The client sends the access token in the
Authorizationheader with requests to any protected microservice. - Access Token Verification: Each microservice, or preferably an API Gateway, verifies the access token's signature and expiration. If valid, the request proceeds.
- Access Token Expiration & Refresh: When the access token expires, the client uses the refresh token (from the HTTP-only cookie) to request a new access token from the Authentication Service's refresh endpoint.
- Refresh Token Validation: The Authentication Service validates the refresh token against its database. If valid and not revoked, a new access token (and optionally a new refresh token for rotation) is issued.
- Logout/Revocation: When a user logs out, the refresh token is removed from the client and revoked from the Authentication Service's database.
3. Step-by-Step Implementation
Let's implement a simplified Authentication Service using Node.js, Express, and JWTs. We'll simulate a database for storing refresh tokens.
Prerequisites:
- Node.js installed
npm install express jsonwebtoken bcryptjs dotenv
File: .env
ACCESS_TOKEN_SECRET="YOUR_ACCESS_TOKEN_SECRET_HERE"
REFRESH_TOKEN_SECRET="YOUR_REFRESH_TOKEN_SECRET_HERE"
REFRESH_TOKEN_EXPIRATION="7d"
ACCESS_TOKEN_EXPIRATION="15m"
File: authService.js (Authentication Service)
require('dotenv').config();
const express = require('express');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
const cookieParser = require('cookie-parser');
const app = express();
const PORT = process.env.PORT || 3000;
app.use(express.json());
app.use(cookieParser());
const users = []; // In-memory user store (for demo purposes)
const refreshTokensDb = []; // In-memory refresh token store (for demo purposes)
// --- Utility Functions ---
const generateAccessToken = (user) => {
return jwt.sign({ userId: user.id }, process.env.ACCESS_TOKEN_SECRET, { expiresIn: process.env.ACCESS_TOKEN_EXPIRATION });
};
const generateRefreshToken = (user) => {
const token = jwt.sign({ userId: user.id }, process.env.REFRESH_TOKEN_SECRET, { expiresIn: process.env.REFRESH_TOKEN_EXPIRATION });
// In a real app, hash this token before storing in DB
refreshTokensDb.push(token); // Store in persistent store (Redis in production)
return token;
};
// --- Authentication Routes ---
// Register
app.post('/register', async (req, res) => {
const { email, password } = req.body;
if (!email || !password) return res.status(400).json({ message: 'Email and password required' });
const hashedPassword = await bcrypt.hash(password, 10);
const user = { id: String(users.length + 1), email, password: hashedPassword };
users.push(user);
res.status(201).json({ message: 'User registered successfully', userId: user.id });
});
// Login
app.post('/login', async (req, res) => {
const { email, password } = req.body;
const user = users.find((u) => u.email === email);
if (!user || !(await bcrypt.compare(password, user.password))) {
return res.status(401).json({ message: 'Invalid credentials' });
}
const accessToken = generateAccessToken(user);
const refreshToken = generateRefreshToken(user);
// Send refresh token inside secure httpOnly cookie to prevent XSS attacks
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
});
res.json({ accessToken, expiresIn: 900 }); // 900 seconds (15m)
});
// Refresh Token Rotation (RTR)
app.post('/refresh', (req, res) => {
const refreshToken = req.cookies.refreshToken;
if (!refreshToken) return res.status(401).json({ message: 'No refresh token provided' });
const tokenIndex = refreshTokensDb.indexOf(refreshToken);
if (tokenIndex === -1) {
return res.status(403).json({ message: 'Refresh token expired or revoked' });
}
jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET, (err, decoded) => {
if (err) {
refreshTokensDb.splice(tokenIndex, 1);
return res.status(403).json({ message: 'Invalid refresh token' });
}
const user = users.find((u) => u.id === decoded.userId);
if (!user) return res.status(404).json({ message: 'User not found' });
// Rotate refresh token: invalidate old, issue new
refreshTokensDb.splice(tokenIndex, 1);
const newAccessToken = generateAccessToken(user);
const newRefreshToken = generateRefreshToken(user);
res.cookie('refreshToken', newRefreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000,
});
res.json({ accessToken: newAccessToken, expiresIn: 900 });
});
});
// Logout & Revocation
app.post('/logout', (req, res) => {
const refreshToken = req.cookies.refreshToken;
if (refreshToken) {
const idx = refreshTokensDb.indexOf(refreshToken);
if (idx !== -1) refreshTokensDb.splice(idx, 1);
}
res.clearCookie('refreshToken');
res.json({ message: 'Logged out successfully' });
});
app.listen(PORT, () => {
console.log(`Auth Service running on http://localhost:${PORT}`);
});
File: resourceService.js (Downstream Microservice)
In a microservices topology, the downstream Resource Microservice verifies the incoming JWT completely statelessly without making any network calls back to the Auth Service or querying a shared session database:
require('dotenv').config();
const express = require('express');
const jwt = require('jsonwebtoken');
const app = express();
const PORT = process.env.RESOURCE_PORT || 4000;
app.use(express.json());
// Stateless JWT Verification Middleware
function authenticateToken(req, res, next) {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
return res.status(401).json({ message: 'Access token missing' });
}
// Pure mathematical verification of cryptographic signature
jwt.verify(token, process.env.ACCESS_TOKEN_SECRET, (err, user) => {
if (err) {
return res.status(403).json({ message: 'Token invalid or expired' });
}
req.user = user;
next();
});
}
// Protected Business API Endpoint
app.get('/api/orders', authenticateToken, (req, res) => {
res.json({
userId: req.user.userId,
orders: [
{ orderId: 'ord_101', item: 'Cloud Server Pro', amountUsd: 120.0 },
{ orderId: 'ord_102', item: 'Managed Database', amountUsd: 85.0 },
],
});
});
app.listen(PORT, () => {
console.log(`Resource Service running on http://localhost:${PORT}`);
});
4. Asymmetric Cryptography (RS256 & JWKS) in Microservices
While the example above uses symmetric signing (HS256), enterprise architectures should adopt asymmetric key pairs (RS256):
┌────────────────────────────────────────────────────────────────────────┐
│ Central Identity Provider (IdP) │
│ Signs JWT with Private Key (RS256) │
│ Exposes Public Key at /.well-known/jwks.json │
└───────────────────────────────────┬────────────────────────────────────┘
│ Stateless JWT Bearer Header
┌────────────────┴────────────────┐
▼ ▼
┌──────────────────────────────┐ ┌──────────────────────────────┐
│ Billing Microservice │ │ Orders Microservice │
│ Caches Public Key from JWKS │ │ Caches Public Key from JWKS │
│ Verifies Signature Locally │ │ Verifies Signature Locally │
└──────────────────────────────┘ └──────────────────────────────┘
- Private Key Isolation: Only the central Auth Service holds the private signing key. If a downstream microservice is breached, the attacker cannot forge authorization tokens.
- Zero-Latency Signature Verification: Resource microservices fetch the JSON Web Key Set (JWKS) once on startup and cache the public certificate. Incoming requests are verified in under 0.2 milliseconds in CPU memory without network roundtrips.
Production Security Checklist
- Short Access Token Lifespan: Access tokens are configured to expire in 5 to 15 minutes.
- HttpOnly, Secure Cookies: Refresh tokens are stored exclusively in
httpOnly,secure,sameSite: "strict"cookies to eliminate XSS theft. - Refresh Token Rotation (RTR): Each token refresh automatically issues a brand new refresh token and deletes the old one.
- Replay / Theft Detection: Reusing an invalidated refresh token triggers automatic revocation of all tokens issued to that user session.
- Asymmetric Signing (RS256): Production systems use RSA or ECDSA private/public key pairs rather than shared symmetric secrets.
Conclusion
Combining short-lived stateless JWTs with stateful, rotated refresh tokens delivers the optimal balance of scalability, security, and developer ergonomics. By enabling downstream microservices to verify identity statelessly while retaining central session revocation authority in Redis, engineering teams can build resilient, horizontally scalable microservice architectures ready for enterprise scale.


