Authentication in a monolith is straightforward: one database, one session store, one login page. In a microservice architecture, every service boundary is an authentication boundary. The API gateway authenticates users. Backend services authenticate to each other. The credential vault authenticates everyone. Getting this wrong means either crippling security or crippling developer velocity — usually both.
This guide covers the full authentication chain for a microservice ecosystem: user login flows, service-to-service JWT patterns, token lifecycle management, and the integration with a credential vault that anchors the trust model.
User Authentication Flow
The login flow follows a well-established pattern, but the details matter enormously:
Client → POST /auth/login (email, password)
→ API Gateway validates input
→ Argon2id password verification
→ Generate access token (JWT, 15m TTL)
→ Generate refresh token (opaque, 7d TTL, stored in DB)
→ Return both tokens
Password Verification with Argon2id
Every password hash should use Argon2id with the OWASP 2024 recommended parameters. Here is the complete Express.js implementation:
import argon2 from 'argon2';
import crypto from 'crypto';
const ARGON2_CONFIG = {
type: argon2.argon2id,
memoryCost: 65536, // 64 MiB
timeCost: 3, // 3 iterations
parallelism: 4, // 4 threads
hashLength: 32,
saltLength: 16
};
async function registerUser(email, password) {
// Argon2 generates and embeds the salt automatically
const hash = await argon2.hash(password, ARGON2_CONFIG);
await prisma.user.create({
data: {
email: email.toLowerCase().trim(),
passwordHash: hash
}
});
}
async function authenticateUser(email, password) {
const user = await prisma.user.findUnique({
where: { email: email.toLowerCase().trim() }
});
if (!user) {
// Prevent user enumeration via timing attack:
// hash a dummy value so response time is consistent
await argon2.hash('dummy-password-for-timing', ARGON2_CONFIG);
return null;
}
const valid = await argon2.verify(user.passwordHash, password);
if (!valid) return null;
// Check if hash needs rehashing (parameters may have changed)
if (argon2.needsRehash(user.passwordHash, ARGON2_CONFIG)) {
const newHash = await argon2.hash(password, ARGON2_CONFIG);
await prisma.user.update({
where: { id: user.id },
data: { passwordHash: newHash }
});
}
return user;
}
The timing-attack mitigation on line 7 of authenticateUser is subtle but critical. Without it, an attacker can distinguish “user does not exist” (fast response) from “wrong password” (slow response, because Argon2id ran). The dummy hash equalizes response times.
The needsRehash check enables transparent parameter upgrades. When you increase memoryCost from 64 MiB to 128 MiB, existing users get rehashed on their next successful login without any migration script.
JWT Token Generation
Access tokens carry claims that downstream services can verify without a database lookup:
import jwt from 'jsonwebtoken';
const JWT_SECRET = process.env.JWT_SECRET; // 256-bit minimum
const JWT_ISSUER = 'auth.example.com';
const JWT_AUDIENCE = 'api.example.com';
function generateAccessToken(user) {
return jwt.sign(
{
sub: user.id,
email: user.email,
roles: user.roles, // ['admin', 'operator']
orgId: user.orgId,
type: 'access'
},
JWT_SECRET,
{
algorithm: 'HS256',
expiresIn: '15m',
issuer: JWT_ISSUER,
audience: JWT_AUDIENCE,
jwtid: crypto.randomUUID()
}
);
}
function generateRefreshToken(user) {
const token = crypto.randomBytes(48).toString('base64url');
const expiresAt = new Date(Date.now() + 7 24 60 60 1000);
// Store in database -- refresh tokens are NOT JWTs
prisma.refreshToken.create({
data: {
token: hashToken(token), // Store hashed, not plaintext
userId: user.id,
expiresAt,
family: crypto.randomUUID() // For rotation detection
}
});
return { token, expiresAt };
}
function hashToken(token) {
return crypto.createHash('sha256').update(token).digest('hex');
}
Key design decisions:
- Access tokens are JWTs because they need to be verified by multiple services without a round-trip to the auth server.
- Refresh tokens are opaque because they only ever go back to the auth server. Making them JWTs would be unnecessary complexity with no benefit.
- Refresh tokens are stored hashed because a database breach should not grant the ability to generate new access tokens.
- The
familyfield enables refresh token rotation detection (explained below).
Service-to-Service Authentication
Microservices authenticate to each other using signed JWTs with strict iss (issuer) and aud (audience) claims:
// Service: Module Management Service (MMS)
// Needs to call: Credential Vault Service (CVS)
function generateServiceToken() {
const SERVICE_SECRET = process.env.CVS_SERVICE_SECRET;
return jwt.sign(
{
sub: 'mms-service',
iss: 'mms.internal',
aud: 'cvs.internal',
scope: ['credential:read', 'credential:write'],
type: 'service'
},
SERVICE_SECRET,
{
algorithm: 'HS256',
expiresIn: '5m',
jwtid: crypto.randomUUID()
}
);
}
// CVS verification middleware
function authenticateService(req, res, next) {
const token = req.headers['x-service-token'];
if (!token) {
return res.status(401).json({ error: 'Service token required' });
}
try {
const decoded = jwt.verify(token, process.env.SERVICE_JWT_SECRET, {
algorithms: ['HS256'], // MUST specify -- prevents alg confusion
audience: 'cvs.internal',
clockTolerance: 30 // 30-second skew allowance
});
// Verify the service is in the allowlist
const allowedServices = ['mms.internal', 'api.internal', 'rms.internal'];
if (!allowedServices.includes(decoded.iss)) {
return res.status(403).json({ error: 'Unknown service' });
}
req.serviceIdentity = decoded;
next();
} catch (err) {
return res.status(403).json({ error: 'Invalid service token' });
}
}
Shared Secrets vs PKI
For small deployments (under 10 services), shared HMAC secrets are pragmatic. Each service pair shares a unique secret, rotated quarterly.
For larger deployments, use asymmetric signing (RS256 or EdDSA):
// Service generates token with its private key
const serviceToken = jwt.sign(payload, PRIVATE_KEY, { algorithm: 'RS256' });
// Receiving service verifies with the sender's public key
const decoded = jwt.verify(serviceToken, SENDER_PUBLIC_KEY, {
algorithms: ['RS256'] // Never omit this
});
Advantages of PKI: services only need each other’s public keys, key compromise on one service does not affect others, and you can implement certificate rotation through a central CA without coordinating secret distribution.
Token Refresh and Rotation
Refresh token rotation is the mechanism that detects token theft:
async function refreshAccessToken(refreshTokenValue) {
const hashedToken = hashToken(refreshTokenValue);
const storedToken = await prisma.refreshToken.findUnique({
where: { token: hashedToken }
});
if (!storedToken || storedToken.expiresAt < new Date()) {
return { error: 'Invalid or expired refresh token' };
}
if (storedToken.revoked) {
// This token was already used -- possible theft!
// Revoke the entire family (all tokens from this lineage)
await prisma.refreshToken.updateMany({
where: { family: storedToken.family },
data: { revoked: true }
});
// Alert the security team
await alertSecurityTeam({
event: 'REFRESH_TOKEN_REUSE',
userId: storedToken.userId,
family: storedToken.family,
timestamp: new Date()
});
return { error: 'Token reuse detected -- all sessions revoked' };
}
// Mark current token as used (but not yet revoked)
await prisma.refreshToken.update({
where: { id: storedToken.id },
data: { revoked: true }
});
// Issue new refresh token in the same family
const newRefreshToken = crypto.randomBytes(48).toString('base64url');
await prisma.refreshToken.create({
data: {
token: hashToken(newRefreshToken),
userId: storedToken.userId,
expiresAt: new Date(Date.now() + 7 24 60 60 1000),
family: storedToken.family
}
});
const user = await prisma.user.findUnique({
where: { id: storedToken.userId }
});
return {
accessToken: generateAccessToken(user),
refreshToken: newRefreshToken
};
}
The family field is the key insight. When a refresh token is used, it is immediately revoked and a new one is issued in the same family. If the old token is ever presented again, it means someone copied it before it was rotated — the entire family is revoked, killing all sessions for that login lineage.
Rate Limiting with Redis
Protect authentication endpoints from brute force with a sliding-window rate limiter:
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
async function rateLimit(key, maxAttempts, windowSeconds) {
const current = await redis.incr(key);
if (current === 1) {
await redis.expire(key, windowSeconds);
}
if (current > maxAttempts) {
const ttl = await redis.ttl(key);
return {
allowed: false,
retryAfter: ttl,
remaining: 0
};
}
return {
allowed: true,
remaining: maxAttempts - current
};
}
// Express middleware
async function loginRateLimiter(req, res, next) {
const ip = req.ip;
const email = req.body.email?.toLowerCase();
// Two rate limits: per-IP and per-account
const ipLimit = await rateLimit(rl:login:ip:${ip}, 20, 900);
const accountLimit = email
? await rateLimit(rl:login:acct:${email}, 5, 900)
: { allowed: true };
if (!ipLimit.allowed) {
res.set('Retry-After', ipLimit.retryAfter);
return res.status(429).json({
error: 'Too many login attempts from this IP',
retryAfter: ipLimit.retryAfter
});
}
if (!accountLimit.allowed) {
res.set('Retry-After', accountLimit.retryAfter);
return res.status(429).json({
error: 'Too many login attempts for this account',
retryAfter: accountLimit.retryAfter
});
}
res.set('X-RateLimit-Remaining', Math.min(ipLimit.remaining, accountLimit.remaining));
next();
}
The dual rate limit is essential. Per-IP catches distributed attacks from a single source. Per-account catches credential stuffing that rotates through proxy IPs.
Common JWT Vulnerabilities and Mitigations
Algorithm Confusion (alg: none)
The most notorious JWT vulnerability: an attacker modifies the token header to {"alg": "none"} and removes the signature. Vulnerable libraries accept it as valid.
Mitigation: Always specify algorithms in the verification call:
// VULNERABLE -- accepts any algorithm including "none"
jwt.verify(token, secret);
// SECURE -- only accepts HS256
jwt.verify(token, secret, { algorithms: ['HS256'] });
This is a one-line fix that eliminates an entire class of attacks.
Token Theft via XSS
If a JWT is stored in localStorage, any XSS vulnerability in your application can exfiltrate it. The attacker gets full API access for the token’s lifetime.
Mitigation: Store access tokens in memory only (JavaScript variable, not storage). Use httpOnly, secure, sameSite=strict cookies for refresh tokens:
res.cookie('refresh_token', refreshToken, {
httpOnly: true, // JavaScript cannot read it
secure: true, // HTTPS only
sameSite: 'strict', // No cross-site transmission
path: '/auth', // Only sent to auth endpoints
maxAge: 7 24 60 60 1000
});
Secret Rotation
When a JWT signing secret is compromised, you need to rotate it without invalidating every active session:
const CURRENT_SECRET = process.env.JWT_SECRET;
const PREVIOUS_SECRET = process.env.JWT_SECRET_PREVIOUS;
function verifyWithRotation(token) {
try {
return jwt.verify(token, CURRENT_SECRET, { algorithms: ['HS256'] });
} catch (err) {
if (PREVIOUS_SECRET && err.name === 'JsonWebTokenError') {
// Try previous secret for tokens issued before rotation
return jwt.verify(token, PREVIOUS_SECRET, { algorithms: ['HS256'] });
}
throw err;
}
}
After rotation, set JWT_SECRET_PREVIOUS to the old value and JWT_SECRET to the new one. All new tokens are signed with the new secret. Old tokens still verify against the previous secret until they expire (maximum 15 minutes with short-lived access tokens). After 15 minutes, remove JWT_SECRET_PREVIOUS.
Credential Vault Integration
The API gateway authenticates to the credential vault using service tokens to retrieve secrets that it needs at runtime (database credentials, third-party API keys, encryption keys):
class VaultClient {
constructor(vaultUrl, serviceId, serviceSecret) {
this.vaultUrl = vaultUrl;
this.serviceId = serviceId;
this.serviceSecret = serviceSecret;
this.tokenCache = null;
this.tokenExpiry = 0;
}
async getServiceToken() {
if (this.tokenCache && Date.now() < this.tokenExpiry - 30000) {
return this.tokenCache;
}
const token = jwt.sign(
{
sub: this.serviceId,
iss: this.serviceId,
aud: 'credential-vault',
type: 'service'
},
this.serviceSecret,
{ algorithm: 'HS256', expiresIn: '5m' }
);
this.tokenCache = token;
this.tokenExpiry = Date.now() + 5 60 1000;
return token;
}
async getCredential(credentialId) {
const token = await this.getServiceToken();
const response = await fetch(${this.vaultUrl}/v1/credentials/${credentialId}, {
headers: {
'Authorization': Bearer ${token},
'X-Service-Identity': this.serviceId
}
});
if (!response.ok) {
throw new Error(Vault returned ${response.status}: ${await response.text()});
}
return response.json();
}
}
// Usage in API gateway startup
const vault = new VaultClient(
process.env.VAULT_URL,
'api-gateway',
process.env.VAULT_SERVICE_SECRET
);
// Retrieve database credentials at startup
const dbCreds = await vault.getCredential('postgres-primary');
The vault client caches its own service token (regenerating 30 seconds before expiry) but never caches the credentials themselves. Credentials are fetched on demand so that rotations take effect immediately.
Putting It All Together: The Trust Chain
The complete authentication architecture forms a chain:
- User authenticates with email + password (Argon2id verification)
- API Gateway issues a JWT access token and an opaque refresh token
- Frontend stores the access token in memory, the refresh token in an httpOnly cookie
- API Gateway validates the access token on each request and proxies to backend services
- Backend services authenticate to each other using service JWTs with strict
iss/audclaims - All services retrieve runtime secrets from the credential vault using service tokens
- The credential vault authenticates service requests, then decrypts credentials through its envelope encryption chain
Every link in this chain has a defined TTL: access tokens expire in 15 minutes, service tokens in 5 minutes, vault sessions in 15 minutes, refresh tokens in 7 days. Every link has a defined rotation mechanism: secrets can be rotated with dual-key verification, refresh tokens rotate on every use, and the vault’s KEK can be rotated without re-encrypting credential payloads.
The result is a system where no single compromise grants persistent access. Steal an access token, and you have 15 minutes. Steal a refresh token, and rotation detection revokes the entire family on the next legitimate use. Compromise a service secret, and dual-key verification limits the blast radius to tokens issued during the rotation window.
Security in microservices is not about building an impenetrable wall. It is about ensuring that every breach is contained, detected, and recoverable. The credential trust chain makes that possible.
