Skip to content

API Security: Authentication and Authorization Patterns

A practical guide to securing APIs with token-based auth, role-based access control, scope permissions and API key management — plus common pitfalls.

5 min read
API security layers showing authentication, authorization, and rate limiting gates

API security is where most breaches happen. Broken authentication is consistently in the OWASP Top 10. The difference between a secure API and a vulnerable one is not choosing the right framework — it is understanding the patterns, implementing them correctly, and knowing where the common mistakes are.

Authentication answers "who are you?" Authorization answers "what are you allowed to do?" They are separate concerns that must be implemented separately. Mixing them creates fragile systems where adding a new role means changing authentication logic.

Token-Based Authentication

JWTs are the standard for stateless API authentication. The server issues a signed token, the client sends it with every request, and the server verifies the signature without hitting a database.

tstypescript
import jwt from 'jsonwebtoken';
import { z } from 'zod';
 
interface TokenPayload {
  sub: string;         // User ID
  roles: string[];     // User roles
  scopes: string[];    // Granted permissions
  iat: number;         // Issued at
  exp: number;         // Expiration
}
 
function generateTokens(
  user: User,
  secret: string,
  refreshSecret: string
) {
  const payload: Omit<TokenPayload, 'iat' | 'exp'> = {
    sub: user.id,
    roles: user.roles,
    scopes: user.scopes,
  };
 
  const accessToken = jwt.sign(payload, secret, {
    expiresIn: '15m',     // Short-lived access token
    algorithm: 'HS256',
  });
 
  const refreshToken = jwt.sign(
    { sub: user.id, type: 'refresh' },
    refreshSecret,
    { expiresIn: '7d', algorithm: 'HS256' }
  );
 
  return { accessToken, refreshToken };
}
 
function verifyAccessToken(
  token: string,
  secret: string
): TokenPayload {
  return jwt.verify(token, secret, {
    algorithms: ['HS256'],  // Explicitly allow only expected algorithms
  }) as TokenPayload;
}
tstypescript
// ❌ Common JWT mistakes
const badToken = jwt.sign(payload, secret, {
  expiresIn: '30d',        // Access tokens should be short-lived (15min)
});
// No algorithm restriction on verify — vulnerable to algorithm confusion
const decoded = jwt.verify(token, secret); // Missing algorithms option
 
// ✅ Secure JWT practices
const goodToken = jwt.sign(payload, secret, {
  expiresIn: '15m',        // Short-lived access token
  algorithm: 'HS256',      // Explicit algorithm
});
const decoded = jwt.verify(token, secret, {
  algorithms: ['HS256'],   // Only accept expected algorithm
  maxAge: '15m',           // Reject expired tokens
});

Authentication Middleware

The authentication middleware extracts the token, verifies it, and attaches the user context to the request. This runs before any route handler.

tstypescript
import type { Request, Response, NextFunction } from 'express';
 
interface AuthenticatedRequest extends Request {
  user: TokenPayload;
}
 
function authenticate(secret: string) {
  return (req: Request, res: Response, next: NextFunction) => {
    const authHeader = req.headers.authorization;
 
    if (!authHeader?.startsWith('Bearer ')) {
      return res.status(401).json({ error: 'Missing authorization header' });
    }
 
    const token = authHeader.slice(7);
 
    try {
      const payload = verifyAccessToken(token, secret);
      (req as AuthenticatedRequest).user = payload;
      next();
    } catch (err) {
      if (err instanceof jwt.TokenExpiredError) {
        return res.status(401).json({ error: 'Token expired' });
      }
      return res.status(401).json({ error: 'Invalid token' });
    }
  };
}
 
// Usage
app.use('/api', authenticate(config.jwtSecret));

Role-Based Access Control

RBAC maps users to roles and roles to permissions. It is the most common authorization pattern because it is simple to understand and implement.

tstypescript
// Define roles and their permissions
const ROLE_PERMISSIONS: Record<string, string[]> = {
  admin: [
    'users:read', 'users:write', 'users:delete',
    'orders:read', 'orders:write', 'orders:delete',
    'reports:read', 'settings:write',
  ],
  manager: [
    'users:read',
    'orders:read', 'orders:write',
    'reports:read',
  ],
  viewer: [
    'orders:read',
    'reports:read',
  ],
};
 
function authorize(...requiredPermissions: string[]) {
  return (req: Request, res: Response, next: NextFunction) => {
    const user = (req as AuthenticatedRequest).user;
 
    // Collect all permissions from the user's roles
    const userPermissions = new Set(
      user.roles.flatMap((role) => ROLE_PERMISSIONS[role] ?? [])
    );
 
    // Check if user has ALL required permissions
    const hasPermission = requiredPermissions.every((perm) =>
      userPermissions.has(perm)
    );
 
    if (!hasPermission) {
      return res.status(403).json({
        error: 'Insufficient permissions',
        required: requiredPermissions,
      });
    }
 
    next();
  };
}
 
// Usage — route-level authorization
app.get('/api/orders', authorize('orders:read'), getOrders);
app.post('/api/orders', authorize('orders:write'), createOrder);
app.delete('/api/orders/:id', authorize('orders:delete'), deleteOrder);
app.get('/api/reports', authorize('reports:read'), getReports);
tstypescript
// ❌ Authorization in route handlers — scattered and error-prone
app.delete('/api/orders/:id', (req, res) => {
  if (req.user.role !== 'admin') {  // Hardcoded role check
    return res.status(403).json({ error: 'Forbidden' });
  }
  // Every route has its own auth logic
  // Easy to forget, easy to get wrong
});
 
// ✅ Authorization as middleware — centralized and consistent
app.delete(
  '/api/orders/:id',
  authorize('orders:delete'),  // Declarative, reusable
  deleteOrder
);
// Permission checks are separate from business logic
// Adding a new role doesn't require modifying route handlers

Resource-Level Authorization

RBAC handles "can this user access orders?" but not "can this user access this specific order?" Resource-level authorization checks ownership and relationships.

tstypescript
async function authorizeResource(
  userId: string,
  resourceType: string,
  resourceId: string,
  action: string,
  db: Database
): Promise<boolean> {
  switch (resourceType) {
    case 'order': {
      const order = await db.query(
        'SELECT customer_id FROM orders WHERE id = $1',
        [resourceId]
      );
      if (order.rows.length === 0) return false;
      // Owner can read and update their own orders
      if (order.rows[0].customer_id === userId) {
        return ['read', 'update'].includes(action);
      }
      return false;
    }
 
    case 'document': {
      const access = await db.query(
        `SELECT permission FROM document_access
         WHERE document_id = $1 AND user_id = $2`,
        [resourceId, userId]
      );
      if (access.rows.length === 0) return false;
      const permission = access.rows[0].permission;
      if (action === 'read') return true;
      if (action === 'write') return permission === 'editor';
      return false;
    }
 
    default:
      return false;
  }
}
 
// Middleware for resource-level auth
function authorizeOwnership(resourceType: string, action: string) {
  return async (req: Request, res: Response, next: NextFunction) => {
    const user = (req as AuthenticatedRequest).user;
    const resourceId = req.params.id;
 
    const allowed = await authorizeResource(
      user.sub,
      resourceType,
      resourceId,
      action,
      req.app.locals.db
    );
 
    if (!allowed) {
      return res.status(403).json({ error: 'Access denied' });
    }
 
    next();
  };
}
 
app.get(
  '/api/orders/:id',
  authorize('orders:read'),                      // Role check
  authorizeOwnership('order', 'read'),           // Ownership check
  getOrder
);

API Key Management

For machine-to-machine communication, API keys are simpler than OAuth tokens. But they require careful management — rotation, scoping, and rate limiting.

tstypescript
import crypto from 'crypto';
 
// Generate a secure API key
function generateApiKey(): { key: string; hash: string } {
  // Generate a cryptographically secure random key
  const key = `sk_live_${crypto.randomBytes(32).toString('hex')}`;
 
  // Store only the hash — never store the raw key
  const hash = crypto
    .createHash('sha256')
    .update(key)
    .digest('hex');
 
  return { key, hash };
}
 
// Verify an API key
async function verifyApiKey(
  key: string,
  db: Database
): Promise<ApiKeyRecord | null> {
  const hash = crypto
    .createHash('sha256')
    .update(key)
    .digest('hex');
 
  const result = await db.query(
    `SELECT id, owner_id, scopes, expires_at, is_active
     FROM api_keys
     WHERE key_hash = $1 AND is_active = TRUE`,
    [hash]
  );
 
  if (result.rows.length === 0) return null;
 
  const record = result.rows[0];
 
  // Check expiration
  if (record.expires_at && new Date(record.expires_at) < new Date()) {
    return null;
  }
 
  // Update last used timestamp
  await db.query(
    'UPDATE api_keys SET last_used_at = NOW() WHERE id = $1',
    [record.id]
  );
 
  return record;
}
shbash
# ❌ API key anti-patterns
# Storing raw API keys in the database
# Using the same key for all environments
# No expiration date on keys
# No way to revoke a compromised key
 
# ✅ API key best practices
# Store only SHA-256 hash of the key
# Prefix keys by environment (sk_live_, sk_test_)
# Set expiration dates and rotation schedules
# Track last_used_at for auditing stale keys
# Scope keys to specific permissions

Refresh Token Rotation

Access tokens expire quickly for security. Refresh tokens allow clients to get new access tokens without re-authenticating. Rotate refresh tokens on every use to detect theft.

tstypescript
async function refreshAccessToken(
  refreshToken: string,
  db: Database,
  config: AuthConfig
): Promise<{ accessToken: string; refreshToken: string }> {
  // Verify the refresh token
  const payload = jwt.verify(refreshToken, config.refreshSecret, {
    algorithms: ['HS256'],
  }) as { sub: string; type: string; jti: string };
 
  if (payload.type !== 'refresh') {
    throw new Error('Invalid token type');
  }
 
  // Check if this refresh token has been used before (reuse detection)
  const tokenRecord = await db.query(
    'SELECT id, used FROM refresh_tokens WHERE jti = $1',
    [payload.jti]
  );
 
  if (tokenRecord.rows.length === 0) {
    throw new Error('Refresh token not found');
  }
 
  if (tokenRecord.rows[0].used) {
    // Token reuse detected — possible theft
    // Invalidate ALL refresh tokens for this user
    await db.query(
      'DELETE FROM refresh_tokens WHERE user_id = $1',
      [payload.sub]
    );
    throw new Error('Refresh token reuse detected — all sessions revoked');
  }
 
  // Mark the current refresh token as used
  await db.query(
    'UPDATE refresh_tokens SET used = TRUE WHERE jti = $1',
    [payload.jti]
  );
 
  // Issue new token pair
  const user = await db.query(
    'SELECT id, roles, scopes FROM users WHERE id = $1',
    [payload.sub]
  );
 
  return generateTokens(user.rows[0], config.jwtSecret, config.refreshSecret);
}

Key Takeaways

  1. Keep access tokens short-lived (15 minutes) — use refresh tokens for longevity; short-lived tokens limit the window of compromise
  2. Separate authentication from authorization — authenticate once in middleware, then check permissions per route with declarative middleware
  3. Store API key hashes, not raw keys — if the database is breached, attackers get useless hashes instead of working keys
  4. Implement resource-level authorization — RBAC alone is not enough; check that users can access the specific resources they are requesting
  5. Rotate refresh tokens on every use — if a stolen refresh token is used, the legitimate user's next refresh attempt reveals the theft
  6. Restrict JWT verification algorithms — always pass an explicit algorithms array to prevent algorithm confusion attacks
Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX