Authentication & Authorization
OAuth 2.0, JWT, sessions, RBAC vs ABAC, API keys — how to know who's calling and what they're allowed to do
Authentication & Authorization
Authentication answers "who are you?" Authorization answers "are you allowed to do that?" They are different concerns, often tangled in code because both happen at the edge of a request.
This page covers the mechanisms — OAuth, JWT, sessions, API keys — and the access-control models that sit on top of them.
OAuth 2.0
OAuth 2.0 is a delegation protocol: it lets a user grant a third-party application limited access to their resources without sharing their password. It is the standard for "Sign in with Google/GitHub/etc."
Flows
| Flow | Use case | Client type |
|---|---|---|
| Authorization Code | Web apps with a server | Confidential (has a backend) |
| Authorization Code + PKCE | SPAs, mobile apps | Public (no secret storage) |
| Client Credentials | Machine-to-machine | Confidential (service accounts) |
| Device Code | TVs, CLI tools, IoT | Input-constrained devices |
Implicit flow is deprecated. If you see it in a tutorial, the tutorial is outdated. Use Authorization Code + PKCE for any public client.
Authorization Code + PKCE (the flow you'll use most)
1. Client generates a random code_verifier and its SHA-256 hash (code_challenge)
2. Client redirects user to authorization server:
GET /authorize?
response_type=code&
client_id=abc&
redirect_uri=https://app.com/callback&
code_challenge=sha256hash&
code_challenge_method=S256&
scope=openid profile email
3. User authenticates and consents
4. Authorization server redirects back with a code:
GET /callback?code=xyz
5. Client exchanges code for tokens (server-side):
POST /token
grant_type=authorization_code&
code=xyz&
redirect_uri=https://app.com/callback&
code_verifier=original_random_string
6. Server returns access_token, refresh_token, id_tokenPKCE prevents authorization code interception — the code_verifier proves that the client that started the flow is the one finishing it.
JWT (JSON Web Tokens)
A JWT is a signed, self-contained token. The server can verify it without a database lookup — the signature is the proof.
Structure
header.payload.signature
// Header (base64url)
{ "alg": "RS256", "typ": "JWT", "kid": "key-2024" }
// Payload (base64url)
{
"sub": "user-42",
"email": "alice@example.com",
"roles": ["admin"],
"iat": 1700000000,
"exp": 1700003600,
"iss": "https://auth.example.com",
"aud": "https://api.example.com"
}
// Signature
RSASHA256(base64url(header) + "." + base64url(payload), privateKey)Signing Algorithms
| Algorithm | Type | When |
|---|---|---|
| RS256 | Asymmetric (RSA) | Default choice. Auth server signs with private key, API verifies with public key. |
| ES256 | Asymmetric (ECDSA) | Shorter signatures, faster verification. Good for high-throughput. |
| HS256 | Symmetric (HMAC) | Both sides share the same secret. Only for single-service setups. |
Never use alg: none. Never accept the algorithm from the token header without validation — this is the classic JWT attack vector.
Validation
Every request with a JWT should verify:
import jwt from 'jsonwebtoken';
import jwksClient from 'jwks-rsa';
const client = jwksClient({
jwksUri: 'https://auth.example.com/.well-known/jwks.json',
cache: true,
rateLimit: true,
});
async function getSigningKey(header: jwt.JwtHeader): Promise<string> {
const key = await client.getSigningKey(header.kid);
return key.getPublicKey();
}
async function verifyToken(token: string) {
const decoded = jwt.decode(token, { complete: true });
if (!decoded) throw new Error('Invalid token');
const publicKey = await getSigningKey(decoded.header);
return jwt.verify(token, publicKey, {
algorithms: ['RS256'], // Whitelist algorithms
issuer: 'https://auth.example.com',
audience: 'https://api.example.com',
clockTolerance: 30, // 30s leeway for clock skew
});
}The checklist:
- Verify the signature against the public key (fetched from JWKS endpoint).
- Check
exp— reject expired tokens. - Check
iss— reject tokens from unknown issuers. - Check
aud— reject tokens not intended for this service. - Whitelist
alg— never trust the header's algorithm blindly.
Refresh Tokens
Access tokens are short-lived (15 min – 1 hour). Refresh tokens are long-lived and exchanged for new access tokens:
// Client-side token refresh
async function refreshAccessToken(refreshToken: string) {
const res = await fetch('https://auth.example.com/token', {
method: 'POST',
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: 'abc',
}),
});
return res.json(); // { access_token, refresh_token, expires_in }
}Refresh token rotation: each refresh returns a new refresh token and invalidates the old one. If a stolen refresh token is used, the legitimate user's next refresh fails — triggering re-authentication and alerting the system.
JWT Pitfalls
- JWTs are not encrypted. The payload is base64-encoded, not encrypted. Anyone can read it. Don't put sensitive data in it.
- You cannot revoke a JWT. Once issued, it's valid until it expires. Mitigation: short expiry + refresh tokens, or maintain a server-side deny list (which partially defeats the "stateless" benefit).
- Token size matters. JWTs go in every request header. A JWT with 50 roles and 20 permissions can exceed header size limits. Keep payloads small; look up details at the API layer.
Session-Based Authentication
Sessions predate JWTs and remain a solid choice for traditional web applications where the client is a browser.
1. User POSTs credentials to /login
2. Server validates, creates a session in the store (Redis, DB)
3. Server sets a cookie: Set-Cookie: sid=abc123; HttpOnly; Secure; SameSite=Strict
4. Browser sends cookie on every subsequent request
5. Server looks up session by sid, finds userSessions vs. JWTs
| Dimension | Sessions | JWTs |
|---|---|---|
| State | Server-side (session store) | Client-side (token) |
| Revocation | Instant (delete session) | Hard (wait for expiry) |
| Scaling | Needs shared session store | Stateless, any server can verify |
| Size | Small cookie (~32 bytes) | Large header (~800+ bytes) |
| Best for | Server-rendered apps, browsers | APIs, mobile, microservices |
Use sessions when: your app is server-rendered, your client is a browser, and you need instant revocation (e.g., "log out everywhere").
Use JWTs when: you have multiple services that need to verify identity independently, or your clients are not browsers.
Token Storage (Browser)
Where you store the token determines your attack surface:
| Storage | XSS vulnerable | CSRF vulnerable | Recommendation |
|---|---|---|---|
localStorage | Yes | No | Avoid for auth tokens |
sessionStorage | Yes | No | Avoid for auth tokens |
HttpOnly cookie | No | Yes (mitigate with SameSite) | Preferred |
| In-memory (JS variable) | Yes | No | OK for short-lived SPA tokens |
The safest browser pattern: store the refresh token in an HttpOnly, Secure, SameSite=Strict cookie. Keep the access token in memory. On page reload, use the refresh token to get a new access token.
Access Control Models
RBAC (Role-Based Access Control)
Assign roles to users, permissions to roles:
const roles = {
admin: ['users:read', 'users:write', 'users:delete', 'orders:*'],
editor: ['users:read', 'orders:read', 'orders:write'],
viewer: ['users:read', 'orders:read'],
};
function authorize(user: User, permission: string): boolean {
const userPermissions = roles[user.role] ?? [];
return userPermissions.some(p =>
p === permission || p === permission.split(':')[0] + ':*'
);
}
// Middleware
function requirePermission(permission: string) {
return (req, res, next) => {
if (!authorize(req.user, permission)) {
return res.status(403).json({ error: 'Forbidden' });
}
next();
};
}
app.delete('/users/:id', requirePermission('users:delete'), deleteUser);RBAC is simple and covers 80% of use cases. It falls short when access depends on relationships ("you can edit your own posts but not others'").
ABAC (Attribute-Based Access Control)
Decisions based on attributes of the user, resource, and context:
// Policy: users can edit their own orders; admins can edit any order
function canEditOrder(user: User, order: Order): boolean {
if (user.role === 'admin') return true;
if (user.id === order.userId) return true;
return false;
}ABAC is more flexible but harder to audit. When you need relationship-based access at scale, look at purpose-built solutions like Oso or OpenFGA (based on Google's Zanzibar paper).
When to Use Which
| Criteria | RBAC | ABAC |
|---|---|---|
| User base | Small-medium, few roles | Large, complex hierarchies |
| Access rules | "Admins can do X" | "Owners can edit their own Y" |
| Auditability | Easy (list roles and permissions) | Harder (policies are code) |
| Implementation | Simple lookup table | Policy engine |
Most apps start with RBAC and add ABAC rules where needed. That is fine.
API Key Management
API keys are for machine-to-machine authentication — not for end users.
Rules:
- Hash before storing. Store
sha256(key)in the database, not the key itself. Show the full key once at creation, never again. - Prefix for identification.
sk_live_abc123tells you it's a secret key for production. The prefix is not secret and helps with log searching. - Scope narrowly. Each key should have the minimum permissions needed.
- Rotate regularly. Support multiple active keys so rotation doesn't cause downtime.
- Rate limit per key. A compromised key with no rate limit is an unlimited attack vector.
import crypto from 'crypto';
function generateApiKey(): { key: string; hash: string } {
const key = `sk_live_${crypto.randomBytes(32).toString('base64url')}`;
const hash = crypto.createHash('sha256').update(key).digest('hex');
return { key, hash };
}
async function validateApiKey(key: string): Promise<ApiKeyRecord | null> {
const hash = crypto.createHash('sha256').update(key).digest('hex');
return db.apiKeys.findOne({ hash, revoked: false });
}Checklist
Before shipping an auth system:
- Passwords are hashed with bcrypt/scrypt/argon2, not SHA-256 or MD5.
- JWTs are signed with RS256 or ES256, verified with a whitelist of algorithms.
- Access tokens expire in under 1 hour.
- Refresh tokens are rotated on each use.
- Cookies are
HttpOnly,Secure,SameSite=Strict. - API keys are hashed at rest, prefixed for identification, scoped, and rate-limited.
- Failed login attempts are rate-limited (by IP and by account).
- Authorization checks happen at the handler level, not just the route level.
- There is a test that proves an unprivileged user cannot access a privileged endpoint.