Authentication Strategies (AuthN)
Authentication answers the question "who are you?" Before your API can decide what a user can do (authorization), it must verify who they claim to be. This article covers OAuth 2.0 flows, session-based vs token-based authentication, and the security trade-offs between them.
OAuth 2.0 Overview
OAuth 2.0 is an authorization framework that enables third-party applications to obtain limited access to a service. Despite the name, OAuth is commonly used for authentication via OpenID Connect (OIDC), which adds an identity layer on top.
Key OAuth 2.0 Roles
- Resource Owner: The user who owns the data
- Client: Your application requesting access
- Authorization Server: Issues tokens (Google, Auth0, your own)
- Resource Server: API that accepts tokens
OAuth 2.0 Flows
Different scenarios require different flows:
| Flow | Use Case | Client Type |
|---|---|---|
| Authorization Code | Server-side web apps | Confidential |
| Authorization Code + PKCE | SPAs, mobile apps | Public |
| Client Credentials | Machine-to-machine | Confidential |
| Device Code | TVs, CLIs, IoT | Input-constrained |
Authorization Code Flow
The standard flow for server-side applications. The client secret stays on the server.
┌──────────┐ ┌───────────────┐
│ Browser │ │ Your Server │
└────┬─────┘ └───────┬───────┘
│ │
│ 1. User clicks "Login with Google" │
│ ──────────────────────────────────────────>│
│ │
│ 2. Redirect to Google │
│ <──────────────────────────────────────────│
│ │
│ 3. User logs in at Google │
│ ──────────────────────────────────────────>│ Google Auth Server
│ │
│ 4. Google redirects back with code │
│ <──────────────────────────────────────────│
│ │
│ 5. Browser hits callback URL │
│ ──────────────────────────────────────────>│
│ │
│ 6. Server exchanges code for tokens (server-to-server)
│ │ ────────────────────>
│ │ <────────────────────
│ │
│ 7. Set session cookie, redirect to app │
│ <──────────────────────────────────────────│
Implementation
// src/routes/auth.ts
import { Router } from 'express';
import { OAuth2Client } from 'google-auth-library';
const router = Router();
const oauth2Client = new OAuth2Client({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
redirectUri: `${process.env.APP_URL}/auth/google/callback`,
});
// Step 1: Initiate OAuth flow
router.get('/auth/google', (req, res) => {
const state = crypto.randomUUID(); // CSRF protection
// Store state in session for verification
req.session.oauthState = state;
const authUrl = oauth2Client.generateAuthUrl({
access_type: 'offline', // Get refresh token
scope: [
'openid',
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
],
state,
prompt: 'consent', // Force consent screen for refresh token
});
res.redirect(authUrl);
});
// Step 2: Handle callback
router.get('/auth/google/callback', async (req, res) => {
const { code, state, error } = req.query;
// Check for OAuth errors
if (error) {
console.error('OAuth error:', error);
return res.redirect('/login?error=oauth_failed');
}
// Verify state to prevent CSRF
if (state !== req.session.oauthState) {
return res.status(403).json({ error: 'Invalid state parameter' });
}
delete req.session.oauthState;
try {
// Exchange code for tokens
const { tokens } = await oauth2Client.getToken(code as string);
oauth2Client.setCredentials(tokens);
// Get user info
const userInfoResponse = await fetch(
'https://www.googleapis.com/oauth2/v3/userinfo',
{ headers: { Authorization: `Bearer ${tokens.access_token}` } }
);
const userInfo = await userInfoResponse.json();
// Find or create user in your database
let user = await db.user.findUnique({
where: { email: userInfo.email },
});
if (!user) {
user = await db.user.create({
data: {
email: userInfo.email,
name: userInfo.name,
picture: userInfo.picture,
googleId: userInfo.sub,
},
});
}
// Store refresh token if provided (for offline access)
if (tokens.refresh_token) {
await db.user.update({
where: { id: user.id },
data: { googleRefreshToken: tokens.refresh_token },
});
}
// Create session
req.session.userId = user.id;
res.redirect('/dashboard');
} catch (err) {
console.error('Token exchange failed:', err);
res.redirect('/login?error=auth_failed');
}
});
// Logout
router.post('/auth/logout', (req, res) => {
req.session.destroy((err) => {
if (err) {
console.error('Session destruction failed:', err);
}
res.clearCookie('sessionId');
res.json({ success: true });
});
});
export default router;
Authorization Code Flow with PKCE
For SPAs and mobile apps, you can't securely store a client secret. PKCE (Proof Key for Code Exchange) adds security by using a dynamic secret.
┌──────────────┐ ┌──────────────────┐
│ SPA/App │ │ Auth Server │
└──────┬───────┘ └────────┬─────────┘
│ │
│ 1. Generate code_verifier (random) │
│ code_challenge = SHA256(verifier) │
│ │
│ 2. Redirect with code_challenge │
│ ────────────────────────────────────────>│
│ │
│ 3. User authenticates │
│ │
│ 4. Redirect back with code │
│ <────────────────────────────────────────│
│ │
│ 5. Exchange code + code_verifier │
│ ────────────────────────────────────────>│
│ │
│ 6. Verify: SHA256(verifier) == challenge │
│ Return tokens │
│ <────────────────────────────────────────│
Implementation
// src/lib/auth-pkce.ts
import { encode as base64UrlEncode } from 'base64url';
// Generate cryptographically random verifier
export const generateCodeVerifier = (): string => {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return base64UrlEncode(Buffer.from(array));
};
// Create challenge from verifier
export const generateCodeChallenge = async (verifier: string): Promise<string> => {
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const hash = await crypto.subtle.digest('SHA-256', data);
return base64UrlEncode(Buffer.from(hash));
};
// src/auth/login.ts (SPA client code)
export const initiateLogin = async () => {
const codeVerifier = generateCodeVerifier();
const codeChallenge = await generateCodeChallenge(codeVerifier);
// Store verifier for later (sessionStorage for SPAs)
sessionStorage.setItem('code_verifier', codeVerifier);
const state = crypto.randomUUID();
sessionStorage.setItem('oauth_state', state);
const params = new URLSearchParams({
client_id: import.meta.env.VITE_AUTH0_CLIENT_ID,
redirect_uri: `${window.location.origin}/callback`,
response_type: 'code',
scope: 'openid profile email',
state,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
});
window.location.href = `https://YOUR_DOMAIN.auth0.com/authorize?${params}`;
};
// src/auth/callback.ts
export const handleCallback = async () => {
const params = new URLSearchParams(window.location.search);
const code = params.get('code');
const state = params.get('state');
// Verify state
if (state !== sessionStorage.getItem('oauth_state')) {
throw new Error('Invalid state');
}
const codeVerifier = sessionStorage.getItem('code_verifier');
if (!code || !codeVerifier) {
throw new Error('Missing code or verifier');
}
// Exchange code for tokens
const response = await fetch('https://YOUR_DOMAIN.auth0.com/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'authorization_code',
client_id: import.meta.env.VITE_AUTH0_CLIENT_ID,
code,
code_verifier: codeVerifier,
redirect_uri: `${window.location.origin}/callback`,
}),
});
const tokens = await response.json();
// Clean up
sessionStorage.removeItem('code_verifier');
sessionStorage.removeItem('oauth_state');
return tokens;
};
Client Credentials Flow
For server-to-server communication where no user is involved. The client authenticates with its own credentials.
// src/lib/machine-auth.ts
interface TokenResponse {
access_token: string;
token_type: string;
expires_in: number;
}
class MachineToMachineAuth {
private token: string | null = null;
private tokenExpiry: number = 0;
constructor(
private clientId: string,
private clientSecret: string,
private tokenUrl: string,
private audience: string
) {}
async getToken(): Promise<string> {
// Return cached token if still valid (with 60s buffer)
if (this.token && Date.now() < this.tokenExpiry - 60000) {
return this.token;
}
const response = await fetch(this.tokenUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'client_credentials',
client_id: this.clientId,
client_secret: this.clientSecret,
audience: this.audience,
}),
});
if (!response.ok) {
throw new Error(`Token request failed: ${response.status}`);
}
const data: TokenResponse = await response.json();
this.token = data.access_token;
this.tokenExpiry = Date.now() + data.expires_in * 1000;
return this.token;
}
}
// Usage
const paymentServiceAuth = new MachineToMachineAuth(
process.env.PAYMENT_CLIENT_ID!,
process.env.PAYMENT_CLIENT_SECRET!,
'https://auth.example.com/oauth/token',
'https://api.payments.example.com'
);
// Call another service
const processPayment = async (amount: number) => {
const token = await paymentServiceAuth.getToken();
return fetch('https://api.payments.example.com/charge', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ amount }),
});
};
Sessions vs JWTs
The two dominant approaches to maintaining authentication state.
Session-Based Authentication
Server stores session data. Client gets an opaque session ID in a cookie.
// src/middleware/session.ts
import session from 'express-session';
import RedisStore from 'connect-redis';
import { createClient } from 'redis';
const redisClient = createClient({ url: process.env.REDIS_URL });
await redisClient.connect();
export const sessionMiddleware = session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET!,
name: 'sessionId', // Cookie name
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === 'production', // HTTPS only in prod
httpOnly: true, // Not accessible via JavaScript
sameSite: 'lax', // CSRF protection
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
},
});
// Auth middleware
export const requireAuth = (req, res, next) => {
if (!req.session.userId) {
return res.status(401).json({ error: { code: 'UNAUTHORIZED' } });
}
next();
};
// Usage
app.use(sessionMiddleware);
app.post('/login', async (req, res) => {
const user = await verifyCredentials(req.body.email, req.body.password);
if (!user) {
return res.status(401).json({ error: { code: 'INVALID_CREDENTIALS' } });
}
// Create session
req.session.userId = user.id;
req.session.userEmail = user.email;
res.json({ data: { user: { id: user.id, email: user.email } } });
});
app.get('/profile', requireAuth, async (req, res) => {
const user = await db.user.findUnique({
where: { id: req.session.userId },
});
res.json({ data: user });
});
app.post('/logout', (req, res) => {
req.session.destroy(() => {
res.clearCookie('sessionId');
res.json({ success: true });
});
});
Session advantages:
- Instant revocation: Delete from Redis, user is immediately logged out
- Server control: Can store arbitrary data, change session contents anytime
- Smaller cookies: Only session ID transmitted, not full payload
- Built-in CSRF protection: Use
sameSite: 'strict'or CSRF tokens
Session disadvantages:
- Server state: Requires session store (Redis, database)
- Scaling complexity: Session store must be shared across servers
- Cross-domain limitations: Cookies don't work across different domains
JWT-Based Authentication
Server issues signed tokens. Client stores and sends token with each request.
// src/lib/jwt.ts
import jwt from 'jsonwebtoken';
const JWT_SECRET = process.env.JWT_SECRET!;
const ACCESS_TOKEN_EXPIRY = '15m';
const REFRESH_TOKEN_EXPIRY = '7d';
interface TokenPayload {
userId: string;
email: string;
}
export const generateTokens = (user: { id: string; email: string }) => {
const accessToken = jwt.sign(
{ userId: user.id, email: user.email },
JWT_SECRET,
{ expiresIn: ACCESS_TOKEN_EXPIRY }
);
const refreshToken = jwt.sign(
{ userId: user.id, type: 'refresh' },
JWT_SECRET,
{ expiresIn: REFRESH_TOKEN_EXPIRY }
);
return { accessToken, refreshToken };
};
export const verifyAccessToken = (token: string): TokenPayload => {
return jwt.verify(token, JWT_SECRET) as TokenPayload;
};
export const verifyRefreshToken = (token: string): { userId: string } => {
const payload = jwt.verify(token, JWT_SECRET) as { userId: string; type: string };
if (payload.type !== 'refresh') {
throw new Error('Invalid token type');
}
return { userId: payload.userId };
};
// src/routes/auth.ts
router.post('/login', async (req, res) => {
const user = await verifyCredentials(req.body.email, req.body.password);
if (!user) {
return res.status(401).json({ error: { code: 'INVALID_CREDENTIALS' } });
}
const { accessToken, refreshToken } = generateTokens(user);
// Store refresh token hash in database for revocation
await db.refreshToken.create({
data: {
userId: user.id,
tokenHash: hashToken(refreshToken),
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
},
});
res.json({
data: {
user: { id: user.id, email: user.email },
accessToken,
refreshToken,
},
});
});
router.post('/refresh', async (req, res) => {
const { refreshToken } = req.body;
try {
const { userId } = verifyRefreshToken(refreshToken);
// Check if refresh token is still valid in database
const storedToken = await db.refreshToken.findFirst({
where: {
userId,
tokenHash: hashToken(refreshToken),
expiresAt: { gt: new Date() },
revokedAt: null,
},
});
if (!storedToken) {
return res.status(401).json({ error: { code: 'INVALID_REFRESH_TOKEN' } });
}
const user = await db.user.findUnique({ where: { id: userId } });
const tokens = generateTokens(user!);
// Rotate refresh token (invalidate old one)
await db.refreshToken.update({
where: { id: storedToken.id },
data: { revokedAt: new Date() },
});
// Create new refresh token
await db.refreshToken.create({
data: {
userId,
tokenHash: hashToken(tokens.refreshToken),
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
},
});
res.json({ data: tokens });
} catch {
res.status(401).json({ error: { code: 'INVALID_REFRESH_TOKEN' } });
}
});
// Auth middleware
export const jwtAuth = (req, res, next) => {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: { code: 'UNAUTHORIZED' } });
}
const token = authHeader.slice(7);
try {
req.user = verifyAccessToken(token);
next();
} catch {
res.status(401).json({ error: { code: 'INVALID_TOKEN' } });
}
};
JWT advantages:
- Stateless: No server-side session store required
- Cross-domain: Works easily across different domains/services
- Scalable: Any server can verify tokens independently
- Self-contained: Token carries user info (reduces database lookups)
JWT disadvantages:
- No instant revocation: Token valid until expiry (use short expiry + refresh tokens)
- Larger payload: Full token sent with every request
- Complexity: Refresh token rotation, secure storage on client
Where to Store JWTs on the Client
| Storage | XSS Risk | CSRF Risk | Recommendation |
|---|---|---|---|
localStorage |
High (accessible via JS) | None | Avoid for sensitive apps |
sessionStorage |
High (accessible via JS) | None | Slightly better (cleared on tab close) |
| HttpOnly Cookie | None | Medium | Best with CSRF protection |
| Memory (variable) | None | None | Best for high security, but lost on refresh |
Recommended approach: Store access token in memory, refresh token in HttpOnly cookie.
// Server - set refresh token as HttpOnly cookie
router.post('/login', async (req, res) => {
const { accessToken, refreshToken } = generateTokens(user);
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000,
path: '/auth/refresh', // Only sent to refresh endpoint
});
res.json({ data: { accessToken } });
});
// Client - store access token in memory
let accessToken: string | null = null;
const login = async (email: string, password: string) => {
const response = await fetch('/api/login', {
method: 'POST',
credentials: 'include', // Include cookies
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const { data } = await response.json();
accessToken = data.accessToken;
};
const fetchWithAuth = async (url: string, options: RequestInit = {}) => {
let response = await fetch(url, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${accessToken}`,
},
});
// If 401, try to refresh
if (response.status === 401) {
const refreshed = await refreshAccessToken();
if (refreshed) {
response = await fetch(url, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${accessToken}`,
},
});
}
}
return response;
};
const refreshAccessToken = async (): Promise<boolean> => {
try {
const response = await fetch('/auth/refresh', {
method: 'POST',
credentials: 'include', // Send refresh token cookie
});
if (!response.ok) return false;
const { data } = await response.json();
accessToken = data.accessToken;
return true;
} catch {
return false;
}
};
Security Considerations
XSS (Cross-Site Scripting)
XSS allows attackers to execute JavaScript in your app's context. If tokens are accessible via JavaScript, they can be stolen.
Mitigations:
- Use HttpOnly cookies when possible
- Sanitize all user input before rendering
- Use Content Security Policy (CSP) headers
- Keep access tokens short-lived
CSRF (Cross-Site Request Forgery)
CSRF tricks users into making unwanted requests to your API using their existing session.
Mitigations:
- Use
SameSite=StrictorSameSite=Laxcookies - Implement CSRF tokens for state-changing operations
- Verify
OriginandRefererheaders
// CSRF token implementation
import csrf from 'csurf';
const csrfProtection = csrf({ cookie: true });
// Get CSRF token
app.get('/csrf-token', csrfProtection, (req, res) => {
res.json({ csrfToken: req.csrfToken() });
});
// Protected route
app.post('/transfer', csrfProtection, (req, res) => {
// CSRF token automatically validated
// ... handle transfer
});
Token Theft
Even with best practices, tokens can be stolen through various attacks.
Mitigations:
- Short access token expiry (15 minutes)
- Refresh token rotation (new refresh token on each use)
- Bind tokens to device/IP (optional, can break mobile)
- Monitor for suspicious activity (multiple locations, unusual times)
// Refresh token rotation with family tracking
router.post('/refresh', async (req, res) => {
const { refreshToken } = req.body;
const { userId } = verifyRefreshToken(refreshToken);
const storedToken = await db.refreshToken.findFirst({
where: { tokenHash: hashToken(refreshToken) },
});
if (!storedToken) {
return res.status(401).json({ error: { code: 'INVALID_TOKEN' } });
}
// If token was already used, revoke entire family (possible theft)
if (storedToken.usedAt) {
await db.refreshToken.updateMany({
where: { familyId: storedToken.familyId },
data: { revokedAt: new Date() },
});
return res.status(401).json({
error: {
code: 'TOKEN_REUSE_DETECTED',
message: 'Session invalidated for security',
},
});
}
// Mark token as used
await db.refreshToken.update({
where: { id: storedToken.id },
data: { usedAt: new Date() },
});
// Issue new tokens in same family
const tokens = generateTokens({ id: userId });
await db.refreshToken.create({
data: {
userId,
tokenHash: hashToken(tokens.refreshToken),
familyId: storedToken.familyId,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
},
});
res.json({ data: tokens });
});
Recommendation Summary
| Scenario | Recommendation |
|---|---|
| Traditional web app (same domain) | Sessions with HttpOnly cookies |
| SPA (same domain) | Sessions or JWT with HttpOnly refresh cookie |
| SPA (cross-domain API) | JWT with short expiry + refresh tokens |
| Mobile app | JWT with secure storage (Keychain/Keystore) |
| Microservices | JWT or OAuth Client Credentials |
| Third-party integrations | OAuth 2.0 with appropriate flow |
See Also
- Authorization (AuthZ) — RBAC and ABAC patterns
- HTTP Best Practices — Status codes and error handling
- OAuth 2.0 Specification
- Auth0 Documentation
- OWASP Authentication Cheatsheet