Steven's Knowledge

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

FlowUse caseClient type
Authorization CodeWeb apps with a serverConfidential (has a backend)
Authorization Code + PKCESPAs, mobile appsPublic (no secret storage)
Client CredentialsMachine-to-machineConfidential (service accounts)
Device CodeTVs, CLI tools, IoTInput-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_token

PKCE 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

AlgorithmTypeWhen
RS256Asymmetric (RSA)Default choice. Auth server signs with private key, API verifies with public key.
ES256Asymmetric (ECDSA)Shorter signatures, faster verification. Good for high-throughput.
HS256Symmetric (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:

  1. Verify the signature against the public key (fetched from JWKS endpoint).
  2. Check exp — reject expired tokens.
  3. Check iss — reject tokens from unknown issuers.
  4. Check aud — reject tokens not intended for this service.
  5. 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 user

Sessions vs. JWTs

DimensionSessionsJWTs
StateServer-side (session store)Client-side (token)
RevocationInstant (delete session)Hard (wait for expiry)
ScalingNeeds shared session storeStateless, any server can verify
SizeSmall cookie (~32 bytes)Large header (~800+ bytes)
Best forServer-rendered apps, browsersAPIs, 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:

StorageXSS vulnerableCSRF vulnerableRecommendation
localStorageYesNoAvoid for auth tokens
sessionStorageYesNoAvoid for auth tokens
HttpOnly cookieNoYes (mitigate with SameSite)Preferred
In-memory (JS variable)YesNoOK 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

CriteriaRBACABAC
User baseSmall-medium, few rolesLarge, complex hierarchies
Access rules"Admins can do X""Owners can edit their own Y"
AuditabilityEasy (list roles and permissions)Harder (policies are code)
ImplementationSimple lookup tablePolicy 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_abc123 tells 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.

On this page