Securing APIs is one of the most critical aspects of modern software development. Poor authentication and authorization implementations lead to data breaches, unauthorized access, and compliance violations. This comprehensive guide explains the concepts, mechanisms, and best practices needed to build secure APIs.
By the end of this guide, you’ll understand the differences between authentication and authorization, know when to use each major security mechanism, and have practical patterns to implement them securely.
Introduction: Why API Security Matters
APIs are the backbone of modern applications, connecting:
- Mobile clients to backend services
- Frontend applications to backend APIs
- Microservices to each other
- Third-party integrations to your platform
Each of these connections needs to answer two critical security questions:
- Who are you? (Authentication)
- What are you allowed to do? (Authorization)
Failure to answer either question correctly can lead to:
- Data breaches: Unauthorized access to sensitive information
- Privilege escalation: Users gaining access to resources they shouldn’t
- Compliance violations: GDPR, HIPAA, PCI-DSS violations
- Reputation damage: Loss of user trust and market share
- Financial impact: Incident response, legal fees, fines
Core Concepts: Authentication vs Authorization
These terms are often confused but represent distinct security concerns.
Authentication: Proving Identity
Authentication answers the question: “Are you who you claim to be?”
Authentication is the process of verifying that someone is actually who they say they are. It’s like showing your passport at an airportโyou’re proving your identity.
Examples:
- Logging in with username and password
- Using a fingerprint scanner
- Providing an API key
- Scanning a QR code with your phone
Key characteristics:
- Happens before authorization
- Usually happens once per session
- Can involve multiple factors (multi-factor authentication)
- Result: Authenticated user/service identity
Authorization: Granting Permissions
Authorization answers the question: “What are you allowed to access?”
Authorization is the process of determining what an authenticated user is allowed to do. It’s like checking if your passport allows you to enter a country.
Examples:
- Admin can delete users; regular user cannot
- Customer can only view their own orders
- API client has read permissions but not write permissions
- User can access
/api/profilebut not/api/admin
Key characteristics:
- Happens after authentication
- Checked on every request
- Based on user roles, attributes, or policies
- Result: Granted or denied access to resources
The Authentication โ Authorization Flow
1. User makes API request with credentials
โ
2. Server authenticates identity (validates credentials)
โ
3. Server verifies authentication succeeded
โ
4. Server checks authorization (user permissions)
โ
5. Server grants or denies access
โ
6. Resource returned or access denied
Authentication Methods: Comparing Approaches
1. API Keys
How it works:
An API key is a unique identifier issued to a client. The client includes the key with every request, and the server validates it.
Implementation:
// Client sends API key in header
GET /api/data HTTP/1.1
X-API-Key: sk_live_abc123def456
// Server validates
app.use((req, res, next) => {
const apiKey = req.headers['x-api-key'];
if (!apiKey) {
return res.status(401).json({ error: 'API key required' });
}
const client = validateApiKey(apiKey);
if (!client) {
return res.status(401).json({ error: 'Invalid API key' });
}
req.client = client;
next();
});
Strengths:
- โ Simple to implement and understand
- โ Easy to rotate and revoke
- โ No user interaction required
- โ Good for server-to-server communication
Weaknesses:
- โ Must be transmitted securely (requires HTTPS)
- โ If leaked, anyone can use it
- โ Not suitable for user-facing applications
- โ Revocation requires all clients to update
- โ No expiration (long-lived, high-risk if leaked)
When to use:
- Third-party API consumption (Stripe, SendGrid, etc.)
- Internal service-to-service communication
- Legacy systems requiring simple authentication
- Public APIs with basic rate limiting needs
Security best practices:
// DO: Validate key format before database lookup
const keyPattern = /^sk_live_[a-z0-9]{32}$/i;
if (!keyPattern.test(apiKey)) {
return res.status(401).json({ error: 'Invalid key format' });
}
// DO: Use key rotation
// Keep both old and new key valid for transition period
const isValid = validateApiKey(apiKey);
if (isValid && isExpired(apiKey)) {
res.set('X-API-Key-Status', 'deprecated');
}
// DO: Hash keys in database, never store plain text
const keyHash = await hash(apiKey);
const stored = await db.apiKeys.findOne({
hash: keyHash,
active: true
});
// DON'T: Log API keys
console.log(`API key: ${apiKey}`); // NEVER DO THIS
// DON'T: Use API keys for user authentication
// API keys are for services, not users
2. Bearer Tokens (OAuth 2.0 / JWT)
How it works:
A bearer token is a security credential that gives access to a protected resource. The token is typically a JWT (JSON Web Token) or opaque token issued by an authorization server.
Implementation:
// Client sends token in Authorization header
GET /api/profile HTTP/1.1
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
// Server validates token
app.use((req, res, next) => {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing token' });
}
const token = authHeader.slice(7); // Remove 'Bearer '
try {
const decoded = verifyJWT(token);
req.user = decoded;
next();
} catch (err) {
return res.status(401).json({ error: 'Invalid token' });
}
});
JWT Structure:
JWTs consist of three parts separated by dots:
header.payload.signature
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
- Header: Algorithm and token type
- Payload: Claims (user ID, permissions, expiration)
- Signature: Server’s signature to verify token wasn’t tampered with
Strengths:
- โ Stateless (server doesn’t need to store tokens)
- โ Self-contained (information in token itself)
- โ Scalable across multiple servers
- โ Can be short-lived (minutes) for security
- โ Standard format (RFC 7519)
Weaknesses:
- โ Cannot revoke instantly (token valid until expiration)
- โ Token grows with claims (larger requests)
- โ Requires HTTPS for secure transmission
- โ Private key compromise means all tokens are invalid
When to use:
- Single-page applications (SPAs)
- Mobile applications
- Microservices architecture
- Third-party API access with expiration
- Any situation requiring stateless authentication
Security best practices:
// DO: Use short expiration times (15-60 minutes)
const token = jwt.sign(
{ userId: user.id },
process.env.JWT_SECRET,
{ expiresIn: '15m' } // Short-lived access token
);
// DO: Implement refresh tokens for long-lived sessions
app.post('/auth/refresh', (req, res) => {
const refreshToken = req.cookies.refreshToken;
if (!refreshToken) {
return res.status(401).json({ error: 'Refresh token missing' });
}
try {
const decoded = verifyJWT(refreshToken, process.env.REFRESH_SECRET);
const newAccessToken = jwt.sign(
{ userId: decoded.userId },
process.env.JWT_SECRET,
{ expiresIn: '15m' }
);
res.json({ accessToken: newAccessToken });
} catch (err) {
res.status(401).json({ error: 'Invalid refresh token' });
}
});
// DO: Store refresh tokens in secure, httpOnly cookies
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: true, // HTTPS only
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
});
// DO: Sign tokens with strong secrets (256 bits minimum)
const secret = crypto.randomBytes(32).toString('hex');
// DO: Verify token signature to detect tampering
try {
jwt.verify(token, secret); // Throws if invalid
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired' });
}
return res.status(401).json({ error: 'Invalid signature' });
}
// DO: Include token revocation for logout
app.post('/auth/logout', (req, res) => {
const token = req.headers.authorization.slice(7);
addTokenToBlacklist(token);
res.clearCookie('refreshToken');
res.json({ message: 'Logged out' });
});
// DON'T: Store sensitive data in JWT (it's base64, not encrypted)
// Bad: jwt.sign({ password: user.password }, secret);
// Good: jwt.sign({ userId: user.id, email: user.email }, secret);
// DON'T: Use weak secrets
// Bad: jwt.sign(payload, 'secret');
// Good: jwt.sign(payload, crypto.randomBytes(32).toString('hex'));
3. OAuth 2.0
How it works:
OAuth 2.0 is an authorization framework that allows users to delegate access without sharing passwords. Users authenticate with a trusted provider (Google, GitHub, etc.), and that provider issues a token that the application can use.
OAuth 2.0 Flow (Authorization Code Flow):
1. User clicks "Sign in with Google"
โ
2. App redirects to Google (with client_id, redirect_uri)
โ
3. User authenticates with Google and consents
โ
4. Google redirects back to app with authorization code
โ
5. App backend exchanges code for token (with client_secret)
โ
6. Google returns access token
โ
7. App uses token to access user's data
Implementation:
// Step 1: Redirect to authorization server
app.get('/auth/google', (req, res) => {
const params = new URLSearchParams({
client_id: process.env.GOOGLE_CLIENT_ID,
redirect_uri: process.env.REDIRECT_URI,
response_type: 'code',
scope: 'openid profile email'
});
res.redirect(`https://accounts.google.com/o/oauth2/v2/auth?${params}`);
});
// Step 2: Handle callback
app.get('/auth/google/callback', async (req, res) => {
const { code } = req.query;
// Exchange code for token
const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
body: JSON.stringify({
code,
client_id: process.env.GOOGLE_CLIENT_ID,
client_secret: process.env.GOOGLE_CLIENT_SECRET,
redirect_uri: process.env.REDIRECT_URI,
grant_type: 'authorization_code'
})
});
const { access_token, refresh_token } = await tokenResponse.json();
// Get user info
const userResponse = await fetch(
'https://www.googleapis.com/oauth2/v2/userinfo',
{ headers: { Authorization: `Bearer ${access_token}` } }
);
const user = await userResponse.json();
// Create or update user in database
const dbUser = await db.users.findOrCreate({
email: user.email,
name: user.name,
googleId: user.id
});
// Create session
const sessionToken = jwt.sign({ userId: dbUser.id }, process.env.JWT_SECRET);
res.cookie('sessionToken', sessionToken, {
httpOnly: true,
secure: true,
sameSite: 'strict'
});
res.redirect('/dashboard');
});
Strengths:
- โ User never shares password with app (password stays with provider)
- โ User can revoke access from provider dashboard
- โ Supports delegated access (different scopes)
- โ Industry standard for user authentication
- โ Supports multiple factors (MFA through provider)
Weaknesses:
- โ Dependency on third-party provider
- โ More complex to implement
- โ User must trust the provider
- โ Privacy concerns (provider knows usage patterns)
When to use:
- Any application requiring user login
- SaaS platforms
- Mobile applications
- Any situation where you want to avoid password management
- Multi-provider support (Google, GitHub, Apple, etc.)
Security best practices:
// DO: Use PKCE (Proof Key for Code Exchange) for public clients
const codeVerifier = crypto.randomBytes(32).toString('hex');
const codeChallenge = crypto
.createHash('sha256')
.update(codeVerifier)
.digest('base64url');
// Include in authorization request
const params = new URLSearchParams({
client_id: process.env.GOOGLE_CLIENT_ID,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
// ... other params
});
// DO: Validate state parameter to prevent CSRF
const state = crypto.randomBytes(32).toString('hex');
req.session.oauthState = state; // Store in session
// Include in authorization request
const params = new URLSearchParams({
state,
// ... other params
});
// In callback, verify state matches
if (req.query.state !== req.session.oauthState) {
return res.status(400).json({ error: 'Invalid state' });
}
// DO: Store refresh tokens securely
await db.users.update(user.id, {
refreshToken: encryptToken(refresh_token)
});
// DO: Use different scopes for different needs
// Minimal scope: only request what you need
const scope = 'openid profile email'; // Not 'https://www.googleapis.com/auth/drive'
// DO: Validate id_token signature
const decoded = jwt.verify(idToken, getGooglePublicKey());
if (decoded.aud !== process.env.GOOGLE_CLIENT_ID) {
throw new Error('Invalid audience');
}
// DON'T: Store access tokens in localStorage (XSS vulnerability)
// Bad: localStorage.setItem('accessToken', token);
// Good: Use httpOnly cookies or session storage
4. Basic Authentication
How it works:
The client sends username and password in the Authorization header, base64-encoded.
Implementation:
// Format: Authorization: Basic base64(username:password)
// Example: Authorization: Basic dXNlcm5hbWU6cGFzc3dvcmQ=
app.use((req, res, next) => {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Basic ')) {
return res.status(401).json({ error: 'Missing credentials' });
}
const credentials = Buffer.from(
authHeader.slice(6),
'base64'
).toString('utf-8');
const [username, password] = credentials.split(':');
const user = validateCredentials(username, password);
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
req.user = user;
next();
});
Strengths:
- โ Simple to implement
- โ Built into HTTP standard
- โ Works with browsers and tools
- โ No session management needed
Weaknesses:
- โ Credentials sent with every request (high exposure risk)
- โ MUST use HTTPS (credentials easily intercepted over HTTP)
- โ Credentials often cached in browsers/clients
- โ No standard way to revoke/logout
- โ Not suitable for third-party applications
When to use:
- Simple internal APIs with HTTPS
- Tool-to-API communication (curl, command line)
- Legacy systems
- Never for public-facing APIs
Security best practices:
// DO: Always use HTTPS
app.use((req, res, next) => {
if (!req.secure && process.env.NODE_ENV === 'production') {
return res.status(400).json({ error: 'HTTPS required' });
}
next();
});
// DO: Rate limit failed attempts
const failedAttempts = new Map();
app.use((req, res, next) => {
const ip = req.ip;
const attempts = failedAttempts.get(ip) || 0;
if (attempts > 5) {
return res.status(429).json({ error: 'Too many failed attempts' });
}
next();
});
// DO: Add delay to slow down brute force attacks
setTimeout(() => next(), Math.random() * 500);
// DON'T: Use Basic Auth with plain text passwords
// DON'T: Accept Basic Auth over HTTP
// DON'T: Store credentials in logs
5. Two-Factor Authentication (2FA)
How it works:
After password authentication, the user must provide a second factor (time-based one-time password, SMS, security key, etc.).
TOTP Implementation (Google Authenticator, Authy):
const speakeasy = require('speakeasy');
const QRCode = require('qrcode');
// Step 1: Generate secret
app.post('/auth/2fa/setup', async (req, res) => {
const secret = speakeasy.generateSecret({
name: `MyApp (${req.user.email})`,
issuer: 'MyApp'
});
// Generate QR code
const qrCode = await QRCode.toDataURL(secret.otpauth_url);
// Store temporary secret in session
req.session.tempSecret = secret.base32;
res.json({
secret: secret.base32,
qrCode,
message: 'Scan this QR code with Google Authenticator or Authy'
});
});
// Step 2: Verify TOTP code
app.post('/auth/2fa/verify', (req, res) => {
const { token } = req.body;
const secret = req.session.tempSecret;
const verified = speakeasy.totp.verify({
secret,
encoding: 'base32',
token,
window: 2 // Allow 2 time windows (ยฑ30 seconds)
});
if (!verified) {
return res.status(401).json({ error: 'Invalid code' });
}
// Store secret for user
db.users.update(req.user.id, {
totpSecret: secret,
twoFactorEnabled: true
});
res.json({ message: '2FA enabled' });
});
// Step 3: Require 2FA on login
app.post('/auth/login', async (req, res) => {
const { email, password } = req.body;
const user = await validatePassword(email, password);
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
if (user.twoFactorEnabled) {
// Don't issue session yet; require 2FA
req.session.pendingUserId = user.id;
return res.json({ requiresMFA: true });
}
// No 2FA required; issue session
issueSession(user, res);
});
// Step 4: Complete 2FA login
app.post('/auth/2fa/verify-login', (req, res) => {
const { token } = req.body;
const userId = req.session.pendingUserId;
const user = db.users.findById(userId);
const verified = speakeasy.totp.verify({
secret: user.totpSecret,
encoding: 'base32',
token,
window: 2
});
if (!verified) {
return res.status(401).json({ error: 'Invalid code' });
}
issueSession(user, res);
});
Strengths:
- โ Significantly more secure than password alone
- โ TOTP is offline (doesn’t require phone network)
- โ Recovery codes provide access if device lost
- โ Prevents account takeover even if password leaked
Weaknesses:
- โ More complex implementation
- โ User friction (extra step to authenticate)
- โ Recovery process needed if user loses device
When to use:
- Admin accounts (always enable)
- High-value accounts (financial, health data)
- User-facing applications
- Compliance requirements (HIPAA, PCI-DSS)
Security best practices:
// DO: Generate recovery codes for account recovery
const recoveryCodes = [];
for (let i = 0; i < 10; i++) {
recoveryCodes.push(crypto.randomBytes(4).toString('hex'));
}
await db.users.update(user.id, {
recoveryCodes: hashRecoveryCodes(recoveryCodes)
});
// Show to user once, never again
res.json({ recoveryCodes });
// DO: Enforce recovery code usage (one-time only)
const hashedCode = hashRecoveryCode(code);
const isValid = user.recoveryCodes.includes(hashedCode);
if (isValid) {
// Remove used code
user.recoveryCodes = user.recoveryCodes.filter(c => c !== hashedCode);
}
// DO: Backup codes for authentication without phone
// DO: Allow multiple 2FA methods (TOTP, SMS, security keys)
// DON'T: Use SMS for 2FA if possible (SIM swapping risk)
// DON'T: Allow users to disable 2FA without strong verification
Comparison Table: Authentication Methods
| Method | Use Case | Complexity | Security | Revocation | Stateless |
|---|---|---|---|---|---|
| API Keys | Service-to-service | Low | Low | Manual | Yes |
| Bearer Tokens (JWT) | SPAs, mobile apps | Medium | Medium | Hard | Yes |
| OAuth 2.0 | User login | High | High | Automatic | Yes |
| Basic Auth | Internal APIs, tools | Low | Low | N/A | Yes |
| 2FA (TOTP) | Admin accounts | High | Very High | Manual | N/A |
Authorization Patterns: Controlling Access
1. Role-Based Access Control (RBAC)
How it works:
Users are assigned roles, and roles have permissions. Access decisions are made based on roles.
Implementation:
// Define roles and permissions
const roles = {
admin: ['read', 'write', 'delete', 'manage_users'],
editor: ['read', 'write', 'delete'],
viewer: ['read']
};
// Middleware to check role
const requireRole = (allowedRoles) => {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({ error: 'Not authenticated' });
}
if (!allowedRoles.includes(req.user.role)) {
return res.status(403).json({ error: 'Insufficient permissions' });
}
next();
};
};
// Usage
app.delete('/users/:id', requireRole(['admin']), (req, res) => {
db.users.delete(req.params.id);
res.json({ message: 'User deleted' });
});
// Middleware to check permission
const requirePermission = (permission) => {
return (req, res, next) => {
const userPermissions = roles[req.user.role] || [];
if (!userPermissions.includes(permission)) {
return res.status(403).json({ error: 'Forbidden' });
}
next();
};
};
// Usage
app.post('/posts', requirePermission('write'), (req, res) => {
const post = db.posts.create(req.body);
res.json(post);
});
Strengths:
- โ Simple to understand and implement
- โ Scales well for most applications
- โ Easy to reason about
- โ Good audit trail
Weaknesses:
- โ Inflexible (can’t express complex rules)
- โ “Role explosion” (too many roles needed)
- โ Can’t express resource-level permissions well
- โ Difficult to implement temporary permissions
When to use:
- Most web applications
- Clear role hierarchy (admin, manager, user)
- Content management systems
- Team collaboration tools
2. Attribute-Based Access Control (ABAC)
How it works:
Access decisions are made based on attributes (user attributes, resource attributes, environment attributes). More flexible than RBAC.
Implementation:
// Define policies using attributes
const policies = [
{
name: 'owner_can_edit_post',
rules: [
{ subject: { attribute: 'id' }, equals: { resource: { attribute: 'authorId' } } },
{ action: 'edit', equals: true }
]
},
{
name: 'admin_can_edit_any_post',
rules: [
{ subject: { attribute: 'role' }, equals: 'admin' },
{ action: 'edit', equals: true }
]
},
{
name: 'published_posts_are_readable',
rules: [
{ resource: { attribute: 'status' }, equals: 'published' },
{ action: 'read', equals: true }
]
}
];
// Policy evaluation engine
function evaluatePolicy(policy, subject, resource, action) {
return policy.rules.every(rule => {
if (rule.subject) {
const subjectValue = getAttributeValue(subject, rule.subject.attribute);
const compareValue = getAttributeValue(resource, rule.equals.resource?.attribute) || rule.equals;
return subjectValue === compareValue;
}
if (rule.action) {
return action === rule.action;
}
if (rule.resource) {
const resourceValue = getAttributeValue(resource, rule.resource.attribute);
return resourceValue === rule.equals;
}
return true;
});
}
// Check authorization
const authorize = (user, resource, action) => {
return policies.some(policy =>
evaluatePolicy(policy, user, resource, action)
);
};
// Usage
app.put('/posts/:id', async (req, res) => {
const post = await db.posts.findById(req.params.id);
if (!authorize(req.user, post, 'edit')) {
return res.status(403).json({ error: 'Forbidden' });
}
const updated = db.posts.update(req.params.id, req.body);
res.json(updated);
});
Strengths:
- โ Highly flexible
- โ Can express complex rules
- โ Scales for large permission sets
- โ Can include environmental factors
Weaknesses:
- โ Complex to implement and maintain
- โ Performance overhead (evaluation required per request)
- โ Difficult to debug
- โ Policy explosion (many overlapping policies)
When to use:
- Complex permission requirements
- Multi-tenant systems
- Time-based permissions
- Context-dependent access (IP ranges, time of day)
- Resource-level fine-grained control
3. Policy-Based Access Control (PBAC)
How it works:
Access control defined through explicit policies that are evaluated in order. Similar to ABAC but more declarative.
Implementation using AWS IAM-like syntax:
// Define policies in JSON
const policies = [
{
version: '2012-10-17',
statement: [
{
sid: 'AllowUsersToReadTheirOwnProfile',
effect: 'Allow',
action: ['users:Read'],
resource: 'arn:myapp:users:${aws:username}'
},
{
sid: 'AllowAdminsToManageUsers',
effect: 'Allow',
action: ['users:*'],
resource: 'arn:myapp:users:*',
condition: {
StringEquals: { 'user:role': 'admin' }
}
}
]
}
];
// Policy evaluation
function evaluatePolicy(policy, subject, action, resource) {
for (const statement of policy.statement) {
const actionMatches = statement.action.some(a =>
a === action || a === '*'
);
if (!actionMatches) continue;
const resourceMatches = matchResource(statement.resource, resource);
if (!resourceMatches) continue;
const conditionsMet = evaluateConditions(
statement.condition,
subject,
resource
);
if (!conditionsMet) continue;
// Explicit Deny always wins
if (statement.effect === 'Deny') {
return false;
}
// Keep checking (Allow doesn't stop evaluation)
}
return false;
}
// Usage
app.get('/users/:id', async (req, res) => {
const authorized = evaluatePolicy(
policy,
req.user,
'users:Read',
`arn:myapp:users:${req.params.id}`
);
if (!authorized) {
return res.status(403).json({ error: 'Forbidden' });
}
const user = await db.users.findById(req.params.id);
res.json(user);
});
Strengths:
- โ Explicit and clear intent
- โ Works well with Infrastructure as Code
- โ Supports Deny to override Allows
- โ Scalable and maintainable
Weaknesses:
- โ More verbose than RBAC
- โ Requires policy infrastructure
- โ Performance considerations
When to use:
- Cloud platforms (AWS, GCP, Azure)
- Enterprise applications with complex governance
- Multi-tenant systems
- Systems requiring audit and compliance
Token Security: Storage and Transmission
Where to Store Tokens
Access Tokens
// BROWSER ENVIRONMENT
// โ
Best: httpOnly cookie
res.cookie('accessToken', token, {
httpOnly: true, // Inaccessible to JavaScript (prevents XSS)
secure: true, // HTTPS only
sameSite: 'strict', // Prevents CSRF
maxAge: 15 * 60 * 1000 // 15 minutes
});
// โ ๏ธ Acceptable: SessionStorage (if no third-party cookies)
// Vulnerable to XSS, but contained to same domain
sessionStorage.setItem('accessToken', token);
// โ Never: localStorage
// Vulnerable to XSS with no way to recover
localStorage.setItem('accessToken', token); // DON'T DO THIS
// MOBILE ENVIRONMENT
// โ
Best: Secure storage (Keychain on iOS, Keystore on Android)
// Use native APIs, not localStorage
// SERVER ENVIRONMENT
// โ
Only: Environment variables or secrets manager
const token = process.env.SERVICE_ACCOUNT_TOKEN;
Refresh Tokens
// Always store refresh tokens in:
// โ
httpOnly, secure, sameSite cookies (browser)
// โ
Secure device storage (mobile)
// โ
Environment variables (server)
// โ Never put refresh tokens in:
// โ localStorage (too vulnerable)
// โ URL parameters (logged in browser history)
// โ Session storage (lost on tab close)
Token Transmission Security
// DO: Use HTTPS for all token transmission
app.use((req, res, next) => {
if (!req.secure && process.env.NODE_ENV === 'production') {
return res.status(400).json({ error: 'HTTPS required' });
}
next();
});
// DO: Use Authorization header
GET /api/data HTTP/1.1
Authorization: Bearer eyJhbGc...
// โ ๏ธ Avoid: Query parameters (logged in server logs, browser history)
GET /api/data?token=eyJhbGc... // Not ideal
// DO: Validate token early in middleware chain
app.use(authMiddleware);
// DO: Never log tokens
console.log(`Token: ${token}`); // NEVER
console.log('User authenticated successfully'); // OK
// DO: Set proper HTTP headers
app.use((req, res, next) => {
res.set({
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
'X-XSS-Protection': '1; mode=block'
});
next();
});
Common Security Vulnerabilities and Mitigations
1. Token Leakage
Vulnerability: Token exposed through logs, error messages, or network traffic.
Mitigation:
// DO: Sanitize logs
console.log('Request', {
...req,
authorization: '***'
}); // OK
// DO: Don't expose tokens in error messages
app.use((err, req, res, next) => {
res.status(500).json({
error: 'Internal server error',
// DON'T include: err.message if it contains token
});
});
// DO: Use secure network protocols (HTTPS, TLS 1.2+)
// DO: Rotate tokens frequently
// DO: Minimize token lifetime (short expiration)
2. Replay Attacks
Vulnerability: Attacker captures a valid token and replays it.
Mitigation:
// DO: Use nonce (number used once)
app.post('/api/action', (req, res) => {
const { nonce } = req.body;
if (usedNonces.has(nonce)) {
return res.status(400).json({ error: 'Nonce already used' });
}
usedNonces.add(nonce);
// Process request
});
// DO: Include request-specific info in token
const token = jwt.sign({
userId: user.id,
timestamp: Date.now()
}, secret);
// DO: Validate timestamp (prevent old tokens)
const age = Date.now() - decoded.timestamp;
if (age > 15 * 60 * 1000) { // 15 minutes
throw new Error('Token too old');
}
// DO: Use JWT jti (JWT ID) claim for one-time use
const token = jwt.sign(
{
userId: user.id,
jti: crypto.randomUUID()
},
secret
);
// Track used JTIs
const usedJtis = new Set();
// DO: Include CSRF token for state-changing operations
app.post('/api/action', (req, res) => {
const csrfToken = req.headers['x-csrf-token'];
if (!validateCsrfToken(csrfToken)) {
return res.status(403).json({ error: 'Invalid CSRF token' });
}
// Process
});
3. Token Expiration Issues
Vulnerability: Tokens never expire or have excessive lifetimes.
Mitigation:
// DO: Set short token lifetimes
const accessToken = jwt.sign(payload, secret, {
expiresIn: '15m' // Short-lived
});
// DO: Use refresh tokens for long sessions
const refreshToken = jwt.sign(payload, secret, {
expiresIn: '7d' // Long-lived
});
// DO: Implement token refresh endpoint
app.post('/auth/refresh', (req, res) => {
const refreshToken = req.cookies.refreshToken;
try {
const decoded = jwt.verify(refreshToken, refreshSecret);
const newAccessToken = jwt.sign(
{ userId: decoded.userId },
accessSecret,
{ expiresIn: '15m' }
);
res.json({ accessToken: newAccessToken });
} catch {
res.status(401).json({ error: 'Refresh token invalid or expired' });
}
});
// DO: Validate expiration on every request
app.use((req, res, next) => {
const token = extractToken(req);
try {
jwt.verify(token, secret); // Throws if expired
next();
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired', code: 'EXPIRED' });
}
return res.status(401).json({ error: 'Invalid token' });
}
});
4. Insufficient Authorization Validation
Vulnerability: Authorization checks are bypassed or incomplete.
Mitigation:
// DO: Check authorization on every request
app.get('/user/:id', requireAuth, async (req, res) => {
// โ Bad: Trust user-provided ID
const user = await db.users.findById(req.params.id); // Could be any user
// โ
Good: Check ownership
if (user.id !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Forbidden' });
}
res.json(user);
});
// DO: Validate permissions on resource operations
app.delete('/posts/:id', requireAuth, async (req, res) => {
const post = await db.posts.findById(req.params.id);
// Verify ownership or admin status
if (post.authorId !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Cannot delete post' });
}
await db.posts.delete(req.params.id);
res.json({ message: 'Post deleted' });
});
// DO: Re-verify critical operations
app.post('/account/delete', requireAuth, requireMFA, async (req, res) => {
// Require re-authentication for sensitive operations
const verified = await reauthenticate(req.user, req.body.password);
if (!verified) {
return res.status(401).json({ error: 'Authentication failed' });
}
await db.users.delete(req.user.id);
res.json({ message: 'Account deleted' });
});
5. Weak Secret Keys
Vulnerability: Secret keys are too short or predictable.
Mitigation:
// DO: Use cryptographically strong random secrets
const secret = crypto.randomBytes(32).toString('hex'); // 256 bits
// DO: Rotate secrets periodically
// Generate new secret
const newSecret = crypto.randomBytes(32).toString('hex');
// Keep old secret temporarily for verification
const validSecrets = [newSecret, currentSecret];
// Update secret after transition period
setTimeout(() => {
validSecrets = [newSecret];
}, 7 * 24 * 60 * 60 * 1000); // 1 week
// DO: Use environment variables for secrets
const jwtSecret = process.env.JWT_SECRET;
// DO: Never hardcode secrets
// โ const secret = 'my-secret'; // NEVER
// โ
const secret = process.env.JWT_SECRET;
// DO: Use secrets management systems (Vault, AWS Secrets Manager)
const secret = await secretsManager.get('jwt-secret');
6. Cross-Site Request Forgery (CSRF)
Vulnerability: Attacker tricks user into making unwanted requests.
Mitigation:
// DO: Use SameSite cookies
res.cookie('sessionToken', token, {
sameSite: 'strict', // or 'lax'
secure: true
});
// DO: Implement CSRF tokens
app.get('/', (req, res) => {
const csrfToken = crypto.randomBytes(32).toString('hex');
req.session.csrfToken = csrfToken;
res.cookie('_csrf', csrfToken);
res.send(html`
<form method="POST" action="/action">
<input type="hidden" name="_csrf" value="${csrfToken}">
...
</form>
`);
});
// Verify on POST
app.post('/action', (req, res) => {
if (req.body._csrf !== req.session.csrfToken) {
return res.status(403).json({ error: 'Invalid CSRF token' });
}
// Process
});
// DO: Use X-CSRF-Token header for AJAX
fetch('/api/action', {
method: 'POST',
headers: {
'X-CSRF-Token': document.querySelector('[name="_csrf"]').value
}
});
Implementation Best Practices
1. Complete Authentication Flow Example
// Express.js example with JWT and refresh tokens
app.post('/auth/login', async (req, res) => {
const { email, password } = req.body;
// Validate input
if (!email || !password) {
return res.status(400).json({ error: 'Email and password required' });
}
// Rate limit
const attempts = getFailedAttempts(email);
if (attempts > 5) {
return res.status(429).json({ error: 'Too many failed attempts' });
}
// Authenticate
const user = await db.users.findByEmail(email);
if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
recordFailedAttempt(email);
return res.status(401).json({ error: 'Invalid credentials' });
}
// Check if 2FA enabled
if (user.twoFactorEnabled) {
req.session.pendingUserId = user.id;
clearFailedAttempts(email);
return res.json({ requiresMFA: true });
}
// Issue tokens
const accessToken = jwt.sign(
{ userId: user.id, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: '15m' }
);
const refreshToken = jwt.sign(
{ userId: user.id },
process.env.REFRESH_SECRET,
{ expiresIn: '7d' }
);
// Store refresh token
await db.refreshTokens.create({
userId: user.id,
token: hashToken(refreshToken),
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
});
clearFailedAttempts(email);
// Return tokens
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000
});
res.json({ accessToken });
});
app.post('/auth/refresh', async (req, res) => {
const refreshToken = req.cookies.refreshToken;
if (!refreshToken) {
return res.status(401).json({ error: 'Refresh token missing' });
}
try {
const decoded = jwt.verify(refreshToken, process.env.REFRESH_SECRET);
// Verify token is stored
const stored = await db.refreshTokens.findOne({
userId: decoded.userId,
token: hashToken(refreshToken)
});
if (!stored) {
return res.status(401).json({ error: 'Refresh token invalid' });
}
// Issue new access token
const accessToken = jwt.sign(
{ userId: decoded.userId },
process.env.JWT_SECRET,
{ expiresIn: '15m' }
);
res.json({ accessToken });
} catch (err) {
res.status(401).json({ error: 'Invalid refresh token' });
}
});
app.post('/auth/logout', (req, res) => {
const refreshToken = req.cookies.refreshToken;
// Revoke refresh token
db.refreshTokens.delete({ token: hashToken(refreshToken) });
res.clearCookie('refreshToken');
res.json({ message: 'Logged out' });
});
// Protected endpoint
app.get('/api/profile', requireAuth, (req, res) => {
const user = db.users.findById(req.user.userId);
res.json(user);
});
// Auth middleware
function requireAuth(req, res, next) {
const token = extractToken(req);
if (!token) {
return res.status(401).json({ error: 'Missing token' });
}
try {
req.user = jwt.verify(token, process.env.JWT_SECRET);
next();
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired' });
}
return res.status(401).json({ error: 'Invalid token' });
}
}
function extractToken(req) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return null;
}
return authHeader.slice(7);
}
2. Security Checklist
// Pre-deployment security checklist
const securityChecklist = {
authentication: {
// Passwords
'โ
Use bcrypt/scrypt for password hashing (never plain text)': true,
'โ
Enforce strong password requirements': true,
'โ
Require password change on first login': true,
// Sessions
'โ
Use secure, httpOnly cookies for session storage': true,
'โ
Implement session timeout (30 min - 1 day)': true,
'โ
Implement logout/session revocation': true,
// MFA
'โ
Enable 2FA for admin accounts': true,
'โ
Support TOTP (not just SMS)': true,
'โ
Provide recovery codes': true,
// Tokens
'โ
Use short token lifetimes (15-60 min)': true,
'โ
Implement refresh tokens with longer lifetime': true,
'โ
Validate token expiration': true,
},
authorization: {
'โ
Implement role-based access control': true,
'โ
Check authorization on every request': true,
'โ
Verify resource ownership before operations': true,
'โ
Deny by default (whitelist approach)': true,
},
transmission: {
'โ
Enforce HTTPS everywhere': true,
'โ
Use TLS 1.2 or higher': true,
'โ
Implement HSTS': true,
'โ
Use secure headers (CSP, X-Frame-Options, etc.)': true,
},
secrets: {
'โ
Use cryptographically strong secrets (256 bits)': true,
'โ
Store secrets in environment variables': true,
'โ
Rotate secrets periodically': true,
'โ
Use secrets management system (Vault, AWS Secrets Manager)': true,
},
monitoring: {
'โ
Log authentication failures': true,
'โ
Implement rate limiting on login': true,
'โ
Monitor suspicious activity (brute force, unusual locations)': true,
'โ
Set up alerts for security events': true,
},
testing: {
'โ
Test authentication bypass scenarios': true,
'โ
Test authorization bypass': true,
'โ
Test token expiration handling': true,
'โ
Test CSRF protection': true,
'โ
Perform security audit': true,
}
};
Quick Decision Guide: Choosing Your Authentication Method
For User-Facing Web Applications
Use OAuth 2.0
- Allows users to sign in with Google, GitHub, Apple, etc.
- Offload password security to trusted providers
- Better user experience (no password to remember)
- Implementation: Next.js with NextAuth.js, or similar
import NextAuth from "next-auth";
import GoogleProvider from "next-auth/providers/google";
export const authOptions = {
providers: [
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
}),
],
};
For Mobile Applications
Use OAuth 2.0 with PKCE + Bearer Tokens
- Mobile apps use OAuth 2.0 Authorization Code flow with PKCE
- Store tokens in secure device storage
- Implement automatic token refresh
- Enable biometric authentication
For Single-Page Applications (SPAs)
Use OAuth 2.0 or Bearer Tokens + Refresh Tokens
- OAuth 2.0 for user login
- JWT bearer tokens for API access
- Store tokens in httpOnly cookies
- Implement automatic refresh before expiration
// Client-side fetch interceptor
const apiClient = axios.create();
apiClient.interceptors.response.use(
response => response,
async error => {
if (error.response?.status === 401) {
const newToken = await refreshAccessToken();
error.config.headers.Authorization = `Bearer ${newToken}`;
return apiClient(error.config);
}
throw error;
}
);
For Server-to-Server Communication
Use API Keys or OAuth 2.0 Client Credentials
- API Keys for simple, long-lived credentials
- OAuth 2.0 Client Credentials for more complex scenarios
- Implement API key rotation
// API Key authentication
app.use((req, res, next) => {
const apiKey = req.headers['x-api-key'];
if (!validateApiKey(apiKey)) {
return res.status(401).json({ error: 'Invalid API key' });
}
next();
});
// OAuth 2.0 Client Credentials
async function getServiceToken() {
const response = await fetch('https://auth.example.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: process.env.CLIENT_ID,
client_secret: process.env.CLIENT_SECRET,
})
});
return response.json();
}
For Admin Dashboards & High-Value Operations
Use Password + 2FA (TOTP or Security Keys)
- Require strong passwords
- Enforce TOTP (Google Authenticator, Authy)
- Optionally support security keys (hardware tokens)
- Require re-authentication for sensitive operations
Conclusion: Key Takeaways
The Three Layers of Security
-
Transmission Security: Use HTTPS everywhere
- Enforce TLS 1.2 or higher
- Implement HSTS
- Use secure headers
-
Authentication: Verify identity
- Use OAuth 2.0 for user login
- Use API Keys/Bearer Tokens for services
- Implement 2FA for sensitive accounts
-
Authorization: Control access
- Implement RBAC for most applications
- Use ABAC for complex scenarios
- Always check permissions on every request
Essential Security Practices
- โ Never store plain text passwords (use bcrypt/scrypt)
- โ Never transmit tokens over HTTP (use HTTPS always)
- โ Use short token lifetimes (15-60 minutes for access tokens)
- โ Implement refresh tokens (7 days to 1 year for refresh tokens)
- โ Check authorization on every request (not just initial login)
- โ Validate input (never trust client data)
- โ Rate limit failed attempts (prevent brute force attacks)
- โ Log security events (failed logins, permission denials, suspicious activity)
- โ Monitor continuously (set up alerts for anomalies)
- โ Test thoroughly (security testing is critical)
Common Implementation Mistakes to Avoid
| Mistake | Why It’s Bad | How to Fix |
|---|---|---|
| Storing passwords in plain text | Any database leak exposes all passwords | Use bcrypt/scrypt with salt |
| Transmitting tokens over HTTP | Tokens easily intercepted | Use HTTPS everywhere |
| Long-lived access tokens | High risk if token stolen | Use 15-60 minute lifetimes |
| Storing tokens in localStorage | Vulnerable to XSS attacks | Use httpOnly cookies |
| No authorization checks | Users can access any resource | Check permissions on every request |
| Not validating expiration | Expired tokens still work | Validate exp claim in every request |
| Weak secret keys | Secrets easily guessed | Use crypto-strong random secrets |
| No 2FA for admins | Admin accounts are high-value targets | Enforce TOTP for admin accounts |
| Logging tokens | Tokens exposed in logs | Sanitize logs, never log tokens |
| No rate limiting | Brute force attacks succeed | Implement rate limiting on auth endpoints |
Next Steps for Implementation
- Assess your current system: What authentication methods are you using?
- Choose your methods: Select appropriate auth methods for your use case
- Implement securely: Follow the patterns in this guide
- Add tests: Write security-focused tests
- Monitor: Set up logging and alerts
- Audit: Have security experts review your implementation
- Keep updated: Stay current with security best practices
Resources for Deeper Learning
- OWASP Authentication Cheat Sheet
- RFC 7234 - HTTP Authentication
- OAuth 2.0 Security Best Current Practice
- NIST Digital Identity Guidelines
- OWASP Top 10
Remember: Security is not a one-time implementation but an ongoing process. Stay vigilant, keep your dependencies updated, and continuously monitor for threats.
Last updated: December 12, 2025
Comments