Authentication is the gateway to every application. Get it wrong, and attackers walk through your front door. Yet despite being a foundational security control, authentication remains one of the most frequently misimplemented components in modern systems. From JWT secret management to OAuth2 redirect validation, from session fixation to refresh token theft, the attack surface is vast.
This post explores how authentication works in real-world systems — not just theory but practical implementation details. We will dive into JSON Web Tokens (JWT), OAuth2 flows, session management, token lifecycles, refresh token strategies, secure storage practices, and the most common security mistakes that lead to account compromise. Each concept includes code snippets you can adapt for production systems.
JWT Deep Dive: Structure and Security
JSON Web Tokens (JWT) are stateless authentication tokens. Unlike traditional sessions that store data on the server, JWTs contain all necessary claims inside the token itself. The server validates the signature — no database lookup required.
A JWT consists of three Base64Url-encoded parts separated by dots:
Header.Payload.Signature
Header contains the signing algorithm and token type:
{
"alg": "HS256",
"typ": "JWT"
}
Payload contains claims — statements about the user and metadata:
{
"sub": "user-12345",
"email": "alice@example.com",
"role": "admin",
"iat": 1704067200,
"exp": 1704070800,
"jti": "unique-token-id-abc123"
}
Standard claims include:
sub (subject) – The user identifier
iat (issued at) – Timestamp when token was created
exp (expiration) – Timestamp when token expires (required for security)
nbf (not before) – Token is invalid before this time
jti (JWT ID) – Unique identifier to prevent replay attacks
iss (issuer) – Who created the token
aud (audience) – Intended recipient
Signature proves token integrity and authenticity:
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)
Generating and Verifying JWTs in Node.js
const jwt = require('jsonwebtoken');
const JWT_SECRET = process.env.JWT_SECRET; // 32+ chars, never hardcoded
const JWT_EXPIRY = '15m'; // Short-lived access tokens
// Generate access token
function generateAccessToken(userId, email, role) {
return jwt.sign(
{
sub: userId,
email: email,
role: role,
jti: crypto.randomUUID()
},
JWT_SECRET,
{ expiresIn: JWT_EXPIRY }
);
}
// Generate refresh token (longer lifespan)
function generateRefreshToken(userId) {
return jwt.sign(
{
sub: userId,
type: 'refresh',
jti: crypto.randomUUID()
},
JWT_SECRET,
{ expiresIn: '7d' }
);
}
// Verify and decode token
function verifyToken(token) {
try {
const decoded = jwt.verify(token, JWT_SECRET, {
algorithms: ['HS256'], // Explicitly allowed algorithms
maxAge: '15m' // Additional validation
});
return { valid: true, decoded };
} catch (error) {
if (error.name === 'TokenExpiredError') {
return { valid: false, reason: 'expired' };
}
if (error.name === 'JsonWebTokenError') {
return { valid: false, reason: 'invalid' };
}
return { valid: false, reason: 'unknown' };
}
}
Critical JWT Security Rules
Never store secrets in code. Use environment variables or a secrets manager. The JWT secret must be cryptographically random — at least 32 bytes (256 bits). Generate one with: openssl rand -base64 32.
Always set short expiration times. Access tokens should live 5-15 minutes. Longer tokens increase the window for theft and abuse. Rotate refresh tokens on each use.
Use strong algorithms. Prefer HS256 (HMAC with SHA-256) for symmetric signing or RS256 (RSA with SHA-256) for asymmetric. Never accept alg: none — this bypasses signature verification entirely.
// ALWAYS validate the algorithm
const decoded = jwt.verify(token, secret, {
algorithms: ['HS256', 'RS256'] // Explicit whitelist
});
Validate all claims. Check exp, aud, iss, and any custom claims. A token from a different audience or issuer should be rejected.
function validateTokenClaims(decoded, expectedAudience, expectedIssuer) {
const now = Math.floor(Date.now() / 1000);
if (decoded.exp && decoded.exp < now) {
throw new Error('Token expired');
}
if (decoded.nbf && decoded.nbf > now) {
throw new Error('Token not yet valid');
}
if (expectedAudience && decoded.aud !== expectedAudience) {
throw new Error('Invalid audience');
}
if (expectedIssuer && decoded.iss !== expectedIssuer) {
throw new Error('Invalid issuer');
}
return true;
}
OAuth2: Delegated Authorization
OAuth2 is not an authentication protocol — it is an authorization framework. It allows a user to grant a third-party application limited access to their resources without sharing credentials. However, OAuth2 is commonly used for authentication via OpenID Connect (OIDC), which adds an identity layer.
OAuth2 Roles and Flow
- Resource Owner – The user who owns the data
- Client – The application requesting access (e.g., "Log in with Google")
- Authorization Server – Issues tokens after user consent (e.g., Google's OAuth server)
- Resource Server – Hosts the protected resources (e.g., Gmail API)
The Authorization Code Flow is the most secure OAuth2 flow for web applications:
// Step 1: Redirect user to authorization server
const authUrl = new URL('https://auth.example.com/authorize');
authUrl.searchParams.append('response_type', 'code');
authUrl.searchParams.append('client_id', CLIENT_ID);
authUrl.searchParams.append('redirect_uri', 'https://app.example.com/callback');
authUrl.searchParams.append('scope', 'openid profile email');
authUrl.searchParams.append('state', crypto.randomBytes(16).toString('hex'));
// Store state in session to prevent CSRF
req.session.oauthState = state;
res.redirect(authUrl.toString());
// Step 2: Handle callback with authorization code
app.get('/callback', async (req, res) => {
const { code, state } = req.query;
// Verify state parameter matches stored value
if (state !== req.session.oauthState) {
return res.status(400).send('Invalid state parameter — possible CSRF attack');
}
// Exchange code for tokens
const tokenResponse = await fetch('https://auth.example.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: code,
redirect_uri: 'https://app.example.com/callback',
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET // Keep this secret!
})
});
const tokens = await tokenResponse.json();
// tokens contains: access_token, refresh_token, id_token (OIDC)
// Validate ID token (for OpenID Connect)
const idToken = jwt.decode(tokens.id_token);
if (idToken.aud !== CLIENT_ID) {
return res.status(400).send('Invalid audience');
}
req.session.user = idToken;
res.redirect('/dashboard');
});
Common OAuth2 Security Mistakes
Missing state parameter. Without state, attackers can initiate the OAuth flow and intercept the callback, binding their account to yours. Always generate and validate a cryptographically random state.
Storing client secrets in frontend code. Public clients (mobile apps, SPAs) cannot keep secrets. Use PKCE (Proof Key for Code Exchange) instead of client secrets.
// PKCE for public clients
const codeVerifier = crypto.randomBytes(32).toString('base64url');
const codeChallenge = crypto
.createHash('sha256')
.update(codeVerifier)
.digest('base64url');
// Send code_challenge in authorization request
// Send code_verifier in token exchange request
Invalid redirect_uri validation. Attackers can redirect authorization codes to their own servers. Validate redirect_uri exactly — not just contains or starts with.
// DANGEROUS — allows redirect to evil.com
if (redirectUri.includes('https://app.example.com')) { }
// SAFE — exact match or registered whitelist
const allowedRedirects = [
'https://app.example.com/callback',
'https://app.example.com/callback-mobile'
];
if (!allowedRedirects.includes(redirectUri)) { reject(); }
Session-Based Authentication
While JWTs are stateless, traditional session authentication stores user data on the server. The client receives only a session ID (usually in a cookie). This approach makes logout and revocation trivial but requires server-side storage.
Secure Session Implementation
const session = require('express-session');
const RedisStore = require('connect-redis').default;
app.use(session({
store: new RedisStore({ client: redisClient }),
name: 'sessionId', // Don't use default 'connect.sid'
secret: process.env.SESSION_SECRET, // 32+ chars
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true, // Prevents XSS access
secure: true, // HTTPS only (requires proxy trust in dev)
sameSite: 'lax', // CSRF protection
maxAge: 1000 * 60 * 60 * 24, // 24 hours
domain: '.example.com' // For subdomain auth
}
}));
// Regenerate session ID after login (prevents session fixation)
app.post('/login', async (req, res) => {
const { username, password } = req.body;
const user = await authenticateUser(username, password);
if (user) {
req.session.regenerate((err) => {
if (err) return res.status(500).send('Login failed');
req.session.userId = user.id;
req.session.role = user.role;
req.session.createdAt = Date.now();
res.json({ success: true });
});
} else {
res.status(401).json({ error: 'Invalid credentials' });
}
});
Session Security Best Practices
Rotate session IDs on privilege changes. When a user logs in or escalates privileges, regenerate the session ID. This prevents session fixation attacks where an attacker pre-sets a session ID.
Set absolute timeouts. Even active users should re-authenticate after a maximum period (e.g., 12 hours for banking, 30 days for social media). Use sliding expiration cautiously.
// Absolute timeout checking middleware
app.use((req, res, next) => {
if (req.session.createdAt && Date.now() - req.session.createdAt > 12 * 60 * 60 * 1000) {
req.session.destroy();
return res.status(401).json({ error: 'Session expired. Please log in again.' });
}
next();
});
Store minimal data in sessions. Never store passwords, credit cards, or other sensitive data in session storage. Session stores are not encrypted by default.
Refresh Token Strategy
Access tokens expire quickly. Refresh tokens allow obtaining new access tokens without re-authentication. However, refresh tokens are powerful — a stolen refresh token provides indefinite access.
Secure Refresh Token Implementation
// Store refresh tokens in database with device fingerprinting
const refreshTokenSchema = {
userId: 'string',
tokenHash: 'string', // Store SHA-256 hash, not raw token
deviceId: 'string', // Derived from User-Agent + IP prefix
expiresAt: 'timestamp',
createdAt: 'timestamp',
revokedAt: 'timestamp' // For logout
};
// Generate and store refresh token
async function issueRefreshToken(userId, deviceId) {
const rawToken = crypto.randomBytes(32).toString('hex');
const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex');
await db.refreshTokens.insert({
userId,
tokenHash,
deviceId,
expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000
});
return rawToken;
}
// Rotate refresh tokens on each use (one-time use)
async function rotateRefreshToken(oldRawToken, deviceId) {
const oldHash = crypto.createHash('sha256').update(oldRawToken).digest('hex');
// Atomic find and delete — prevents replay
const tokenRecord = await db.refreshTokens.findOneAndDelete({
tokenHash: oldHash,
deviceId: deviceId,
revokedAt: { $exists: false }
});
if (!tokenRecord) {
// Possible replay attack — revoke all user tokens
await revokeAllUserTokens(tokenRecord.userId);
throw new Error('Invalid or replayed refresh token');
}
// Issue new token
return issueRefreshToken(tokenRecord.userId, deviceId);
}
// Refresh endpoint
app.post('/auth/refresh', async (req, res) => {
const { refreshToken } = req.body;
const deviceId = req.headers['x-device-id'];
if (!refreshToken || !deviceId) {
return res.status(400).json({ error: 'Missing refresh token or device ID' });
}
try {
const newRefreshToken = await rotateRefreshToken(refreshToken, deviceId);
const newAccessToken = generateAccessToken(userId);
res.json({
accessToken: newAccessToken,
refreshToken: newRefreshToken,
expiresIn: 900 // 15 minutes
});
} catch (error) {
res.status(401).json({ error: 'Invalid refresh token' });
}
});
Refresh Token Security Rules
Implement token rotation. Each refresh token should be usable exactly once. The rotation endpoint issues a new token and invalidates the old one. This limits damage from token theft.
Detect token replay. If an attacker steals a refresh token and uses it, the legitimate user's next refresh will fail (token already used). Detect this and revoke all tokens for that user.
Bind tokens to device fingerprints. Store a hash of User-Agent and IP prefix (first three octets) with the token. Reject refreshes from inconsistent devices.
Implement refresh limits. After 5 refreshes within 10 minutes, require re-authentication. Automated refresh loops indicate possible abuse.
Token Storage in Frontend Applications
How and where you store tokens determines their exposure to XSS and CSRF attacks.
Secure Storage Options
HttpOnly cookies are the most secure for session tokens. They are inaccessible to JavaScript, preventing XSS theft. Set Secure, HttpOnly, and SameSite=Strict flags.
// Set JWT in HttpOnly cookie (not localStorage)
res.cookie('accessToken', accessToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 15 * 60 * 1000 // 15 minutes
});
Memory storage (JavaScript variables) is secure against CSRF but lost on page refresh. Acceptable for SPAs that refresh tokens on each load.
NEVER use localStorage or sessionStorage for sensitive tokens. Any XSS vulnerability (even from third-party libraries) allows attackers to read localStorage and steal tokens.
// DANGEROUS — XSS can read this
localStorage.setItem('jwt', accessToken);
// SAFE — HttpOnly cookie (recommended) or memory variable
Common Authentication Security Mistakes
Missing brute-force protection. Always implement rate limiting on login endpoints, password reset, and token refresh. Use exponential backoff or CAPTCHA after failed attempts.
const rateLimit = require('express-rate-limit');
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 attempts
skipSuccessfulRequests: true,
keyGenerator: (req) => req.body.email || req.ip
});
app.post('/login', loginLimiter, handleLogin);
Timing attacks on login. Compare passwords and tokens using constant-time functions to prevent attackers from guessing valid usernames based on response time.
// DANGEROUS — early exit reveals valid username
if (!user) return 'Invalid credentials';
if (!bcrypt.compareSync(password, user.hash)) return 'Invalid credentials';
// SAFE — constant time comparison
const user = await db.findUserByEmail(email);
const isValid = user && await bcrypt.compare(password, user.hash);
if (!isValid) {
await delay(100); // Consistent response time
return 'Invalid credentials';
}
Weak password hashing. Never store passwords in plaintext. Never use MD5 or SHA1. Use bcrypt, Argon2, or PBKDF2 with high iteration counts.
const bcrypt = require('bcrypt');
const saltRounds = 12; // Adjust for your hardware (10-14 range)
async function hashPassword(plaintext) {
return await bcrypt.hash(plaintext, saltRounds);
}
Missing logout functionality. Logout must invalidate both access tokens (client-side deletion) and refresh tokens (server-side revocation).
app.post('/logout', async (req, res) => {
const refreshToken = req.body.refreshToken;
if (refreshToken) {
const tokenHash = crypto.createHash('sha256').update(refreshToken).digest('hex');
await db.refreshTokens.updateOne(
{ tokenHash },
{ $set: { revokedAt: new Date() } }
);
}
res.clearCookie('accessToken');
res.clearCookie('refreshToken');
res.json({ success: true });
});
Final Thoughts
Authentication is harder than it looks. JWTs offer stateless scalability but require careful secret management and short lifetimes. OAuth2 adds delegation but demands proper state validation and PKCE for public clients. Sessions provide simple revocation but need secure cookie configuration and storage.
The most secure authentication systems combine multiple approaches: short-lived JWTs stored in HttpOnly cookies, refresh tokens with rotation and device binding, rate-limited endpoints, and constant-time comparisons.
Remember: never roll your own cryptography. Use well-tested libraries — jsonwebtoken, bcrypt, OAuth2 client libraries, and express-session. Security audits and penetration testing are not optional for authentication code.
Your users trust you with their identities. Implement authentication like someone is actively trying to break it — because they are.
.