Skip to main content

Authentication and Authorization: API Security Patterns

Security is the part of API design that most tutorials leave for last — a chapter bolted on after everything else is designed. That's exactly backwards.

Authentication affects your URL structure (do login endpoints follow the same resource patterns?). Authorization affects your response design (do you return 403 or 404 when a user requests a resource they can't see?). The OAuth 2.0 flow you choose determines what your clients look like and what your token infrastructure needs to support. These aren't afterthoughts — they're design decisions that ripple across everything you've already built.

This article covers API security as a first-class design concern. You'll learn what each security pattern is, when to use it, and how each one maps onto TaskFlow's endpoints. By the end, you'll have designed TaskFlow's complete authentication and authorization scheme — and added it to the OpenAPI spec you wrote in the previous article.


Quick Reference

The four patterns, from simplest to most complex:

PatternBest ForMain Limitation
API KeysServer-to-server, developer integrationsNo user identity, hard to rotate at scale
HTTP Basic AuthInternal tools, simple admin accessPassword in every request; only use over HTTPS
Bearer Tokens / JWTUser-facing APIs, mobile and web clientsToken expiry management; needs refresh flow
OAuth 2.0Third-party access, delegated authorizationSignificant implementation complexity

JWT structure:

header.payload.signature
eyJhbGci... .eyJzdWIi... .SflKxwRJ...

The 401 vs 403 rule (repeated because it matters):

  • 401 Unauthorized → not authenticated (no credentials or invalid credentials)
  • 403 Forbidden → authenticated but not authorized (correct identity, wrong permissions)

OAuth 2.0 flow selector:

  • Web app (server-side rendered) → Authorization Code
  • Single-page app or mobile → Authorization Code + PKCE
  • Server-to-server (no user) → Client Credentials
  • Legacy password flow → Resource Owner Password (avoid for new systems)

Gotchas:

  • ⚠️ Never store JWTs in localStorage — use httpOnly cookies or memory
  • ⚠️ Always validate the JWT signature server-side — never trust the payload without verifying
  • ⚠️ Short-lived access tokens (15 min) + long-lived refresh tokens is the correct pattern
  • ⚠️ Log auth failures — they're your first signal of a credential stuffing attack

See also:


Version Information

Relevant specifications:

Libraries referenced:

  • jsonwebtoken: ^9.0
  • bcrypt: ^5.0
  • @types/jsonwebtoken: ^9.0

Last verified: June 2025


What You Need to Know First

Required reading (in order):

  1. REST APIs: What They Are and How They Work — statelessness is the foundation for why credentials travel with every request
  2. HTTP Methods and Status Codes: The Full Picture — the 401/403 distinction is used throughout this article
  3. OpenAPI Specification: Documenting Your API — we'll complete the securitySchemes section from the spec you wrote there

Helpful background:

  • Basic understanding of how HTTPS works (what encryption at the transport layer means)
  • Familiarity with base64 encoding (used in JWT structure)

What We'll Cover in This Article

By the end of this guide, you'll understand:

  • The difference between authentication and authorization — and why both matter
  • API Keys — how they work, when to use them, how to issue and validate them
  • HTTP Basic Auth — structure, correct use cases, and limitations
  • Bearer tokens and JWTs — structure, signing, validation, and the refresh token pattern
  • OAuth 2.0 — the four grant types and which flow fits which client type
  • How to apply the correct security scheme to every TaskFlow endpoint
  • How to document the complete auth scheme in the OpenAPI spec

What We'll Explain Along the Way

Don't worry if you're unfamiliar with these — we'll define them as we encounter them:

  • Symmetric vs asymmetric JWT signing (HS256 vs RS256)
  • PKCE (Proof Key for Code Exchange)
  • Scopes and claims in JWTs
  • The concept of a refresh token
  • Token revocation and its challenges

Authentication vs Authorization

These two words are often used interchangeably. They mean different things, and conflating them causes real bugs.

Authentication answers: Who are you? It's the process of verifying that a caller is who they claim to be. A username and password, a JWT, an API key — all of these are authentication mechanisms. After successful authentication, the server knows the caller's identity.

Authorization answers: What are you allowed to do? Given that the server knows who you are, it now decides whether you have permission to perform the requested action. Authorization happens after authentication — you can't authorize someone whose identity you haven't verified.

In REST terms:

Request with no credentials
→ Server can't identify caller
→ 401 Unauthorized ("tell me who you are")

Request with valid credentials (user A)
→ Server identifies caller as user A
→ User A requests resource owned by user B
→ Server knows who user A is, but A doesn't have permission
→ 403 Forbidden ("I know who you are — you can't do this")

Both are security responses. They signal completely different problems to the client.


Pattern 1: API Keys

An API key is a long, randomly generated string that a client includes with every request to identify itself. It's the simplest form of authentication.

GET /v1/tasks
Authorization: ApiKey tf_live_8kJd92nAm4pQr5sUv9wXy1zA3bC6dE

Or sometimes as a query parameter (less secure — URLs are logged):

GET /v1/tasks?api_key=tf_live_8kJd92nAm4pQr5sUv9wXy1zA3bC6dE

How API Keys Work

  1. A developer registers for API access and receives a key
  2. The key is stored in the server's database, hashed (like a password)
  3. The client includes the key in every request
  4. The server hashes the incoming key and compares it to the stored hash
  5. If they match, the request is authenticated as that developer's account
import crypto from "crypto";
import { db } from "./database";

interface ApiKey {
id: string;
keyHash: string; // Store the hash, never the raw key
clientId: string;
scopes: string[];
createdAt: string;
lastUsedAt: string | null;
expiresAt: string | null;
}

// Generating a new API key
function generateApiKey(): { raw: string; hash: string; prefix: string } {
// Prefix identifies the key type and environment at a glance
const prefix = "tf_live_";
const rawSecret = crypto.randomBytes(32).toString("base64url");
const raw = `${prefix}${rawSecret}`;

// Hash before storing — never store the raw key
const hash = crypto.createHash("sha256").update(raw).digest("hex");

return { raw, hash, prefix };
}

// Validating an incoming API key
async function validateApiKey(
rawKey: string,
): Promise<{ valid: boolean; clientId?: string; scopes?: string[] }> {
const hash = crypto.createHash("sha256").update(rawKey).digest("hex");

const apiKey = await db.apiKeys.findByHash(hash);

if (!apiKey) {
return { valid: false };
}

// Check expiry
if (apiKey.expiresAt && new Date(apiKey.expiresAt) < new Date()) {
return { valid: false };
}

// Update last used timestamp (fire-and-forget)
db.apiKeys.updateLastUsed(apiKey.id).catch(console.error);

return {
valid: true,
clientId: apiKey.clientId,
scopes: apiKey.scopes,
};
}

API Key Design Decisions

Key format: Use a recognizable prefix (tf_live_, tf_test_) so developers can immediately identify what they're looking at — and so secret scanning tools (GitHub, GitLab) can detect accidentally committed keys.

Hashing: Never store raw API keys — treat them like passwords. Use SHA-256 for API keys (fast enough for lookup, sufficient for a random secret). Use bcrypt for passwords (deliberately slow to resist brute force).

Scopes: Attach scopes to each key to limit what it can do. A key with tasks:read can list tasks but not create them.

Expiry: Give keys an optional expiry date. Many enterprise customers require this for compliance.

Test vs live keys: Issue separate keys for test and production environments. A test key (tf_test_) should only work against test data.

When to Use API Keys

API keys are the right choice when:

  • You're building a developer integration — third-party systems calling your API server-to-server
  • You need a simple, auditable credential without user identity complexity
  • The client is a backend service, not a browser or mobile app

API keys are the wrong choice when:

  • You need to identify individual users (not just the application calling the API)
  • You need fine-grained permissions per user (not just per application)
  • The key would be exposed to a browser (browsers are not secure key stores)

TaskFlow uses API keys for its developer integration tier — third-party apps that want to read or write TaskFlow data on behalf of their own service accounts.


Pattern 2: HTTP Basic Authentication

HTTP Basic Auth sends credentials as a base64-encoded username:password string in the Authorization header:

Authorization: Basic dXNlckBleGFtcGxlLmNvbTpteXBhc3N3b3Jk

That encoded string decodes to: user@example.com:mypassword

The server decodes it, splits on :, and validates the credentials.

import { Request, Response, NextFunction } from "express";
import bcrypt from "bcrypt";
import { db } from "./database";

async function basicAuthMiddleware(
req: Request,
res: Response,
next: NextFunction,
) {
const authHeader = req.headers.authorization;

if (!authHeader?.startsWith("Basic ")) {
return res.status(401).json({
error: {
code: "authentication_required",
message: "Basic authentication required.",
details: [],
},
});
}

// Decode the base64 credentials
const encoded = authHeader.slice(6); // Remove "Basic "
const decoded = Buffer.from(encoded, "base64").toString("utf-8");
const [email, password] = decoded.split(":");

if (!email || !password) {
return res.status(401).json({
error: {
code: "invalid_credentials",
message: "Malformed Basic Auth credentials.",
details: [],
},
});
}

const user = await db.users.findByEmail(email);

if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
// Constant-time comparison to prevent timing attacks
return res.status(401).json({
error: {
code: "invalid_credentials",
message: "Invalid email or password.",
details: [],
},
});
}

req.user = user;
next();
}

When to Use Basic Auth

Basic Auth is appropriate when:

  • The API is internal only — accessed by tools your team controls, never by end users
  • You need the absolute simplest credential mechanism for an admin endpoint
  • The connection is always HTTPS — Basic Auth is plaintext; without TLS it exposes passwords

Basic Auth is the wrong choice when:

  • Any client might be a browser or mobile app
  • Credentials could be logged (proxy logs, server logs) — base64 is trivially reversible
  • You need token rotation or revocation

TaskFlow does not use Basic Auth for any user-facing endpoints. It's available only on internal admin tooling, always behind HTTPS and IP allowlisting.


Pattern 3: Bearer Tokens and JWTs

Bearer tokens are the dominant authentication pattern for modern web and mobile APIs. The client obtains a token (through login), includes it in every subsequent request, and the server validates it.

POST /v1/auth/login
Content-Type: application/json

{ "email": "alex@example.com", "password": "hunter2" }

→ 200 OK
{
"data": {
"accessToken": "eyJhbGci...",
"refreshToken": "eyJhbGci...",
"expiresIn": 900 // 15 minutes in seconds
}
}

Subsequent requests:

GET /v1/tasks
Authorization: Bearer eyJhbGci...

The token is "bearer" in the sense that whoever bears (holds) it is authenticated — the server doesn't verify the holder's identity beyond possession of the token.

What Is a JWT?

A JWT (JSON Web Token, pronounced "jot") is a specific token format defined in RFC 7519. It's the most widely used token format for REST APIs because it's self-contained — the token itself carries the information the server needs, without a database lookup.

A JWT has three parts, separated by dots:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.
eyJzdWIiOiJ1c2VyXzQyIiwibmFtZSI6IkFsZXggSm9obnNvbiIsImlhdCI6MTcxOTQzMjAwMCwiZXhwIjoxNzE5NDMyOTAwfQ
.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
  • Header (first part) — algorithm and token type, base64url-encoded
  • Payload (second part) — claims (data about the user), base64url-encoded
  • Signature (third part) — cryptographic signature over header + payload

Decoding the header:

{
"alg": "HS256",
"typ": "JWT"
}

Decoding the payload:

{
"sub": "user_42", // Subject — the user this token represents
"name": "Alex Johnson",
"email": "alex@example.com",
"scopes": ["tasks:read", "tasks:write"],
"iat": 1719432000, // Issued at (Unix timestamp)
"exp": 1719432900 // Expires at (Unix timestamp) — 15 min later
}

The payload is base64url-encoded, not encrypted. Anyone who holds a JWT can decode and read its payload — it's not a secret. The signature is what makes it tamper-proof: it proves the payload was issued by your server and hasn't been modified.

Diagram: JWT construction. The header and payload are base64url-encoded and concatenated, then signed to produce the final token. The signature is what prevents tampering.

Signing Algorithms: HS256 vs RS256

Two algorithms dominate JWT signing:

HS256 (HMAC-SHA256) — Symmetric:

  • Uses a single secret key to both sign and verify
  • The same secret that signs tokens also verifies them
  • Simple to implement — one secret to manage
  • Problem: any service that needs to verify tokens must have the signing secret — creating a secret-sharing problem in multi-service architectures

RS256 (RSA-SHA256) — Asymmetric:

  • Uses a private key to sign, a public key to verify
  • Only the auth server holds the private key
  • Any service can verify tokens using the public key (safe to distribute)
  • Better for microservices — publish the public key, each service verifies independently
import jwt from "jsonwebtoken";

// ─── HS256 (symmetric) ──────────────────────────────
const SECRET = process.env.JWT_SECRET!; // Single shared secret

function signTokenHS256(userId: string, scopes: string[]): string {
return jwt.sign(
{
sub: userId,
scopes,
},
SECRET,
{
algorithm: "HS256",
expiresIn: "15m",
issuer: "api.taskflow.com",
},
);
}

function verifyTokenHS256(token: string): jwt.JwtPayload {
// Throws if invalid, expired, or tampered with
return jwt.verify(token, SECRET, {
algorithms: ["HS256"], // Never omit — prevents algorithm confusion attacks
issuer: "api.taskflow.com",
}) as jwt.JwtPayload;
}

// ─── RS256 (asymmetric) ─────────────────────────────
const PRIVATE_KEY = process.env.JWT_PRIVATE_KEY!;
const PUBLIC_KEY = process.env.JWT_PUBLIC_KEY!;

function signTokenRS256(userId: string, scopes: string[]): string {
return jwt.sign({ sub: userId, scopes }, PRIVATE_KEY, {
algorithm: "RS256",
expiresIn: "15m",
issuer: "api.taskflow.com",
});
}

function verifyTokenRS256(token: string): jwt.JwtPayload {
return jwt.verify(token, PUBLIC_KEY, {
algorithms: ["RS256"],
issuer: "api.taskflow.com",
}) as jwt.JwtPayload;
}

TaskFlow uses RS256 — the architecture anticipates multiple services (a notification service, a search service) that need to verify tokens without holding the signing secret.

JWT Validation Middleware

import { Request, Response, NextFunction } from "express";
import jwt from "jsonwebtoken";

const PUBLIC_KEY = process.env.JWT_PUBLIC_KEY!;

interface TokenPayload extends jwt.JwtPayload {
sub: string;
scopes: string[];
}

// Attach validated user to the request
declare global {
namespace Express {
interface Request {
user?: { id: string; scopes: string[] };
}
}
}

export function requireAuth(
req: Request,
res: Response,
next: NextFunction,
): void {
const authHeader = req.headers.authorization;

// Step 1: Check header presence and format
if (!authHeader?.startsWith("Bearer ")) {
res.status(401).json({
error: {
code: "authentication_required",
message: "Provide a valid Bearer token in the Authorization header.",
details: [],
},
});
return;
}

const token = authHeader.slice(7); // Remove "Bearer "

try {
// Step 2: Verify signature, expiry, and issuer
const payload = jwt.verify(token, PUBLIC_KEY, {
algorithms: ["RS256"], // Explicit allowlist — never omit
issuer: "api.taskflow.com",
}) as TokenPayload;

// Step 3: Attach identity to request for downstream handlers
req.user = {
id: payload.sub,
scopes: payload.scopes ?? [],
};

next();
} catch (err) {
if (err instanceof jwt.TokenExpiredError) {
res.status(401).json({
error: {
code: "token_expired",
message:
"Your access token has expired. Use your refresh token to obtain a new one.",
details: [],
},
});
return;
}

// Invalid signature, malformed token, wrong issuer, etc.
res.status(401).json({
error: {
code: "invalid_credentials",
message: "The provided token is invalid.",
details: [],
},
});
}
}

// Authorization check — run after requireAuth
export function requireScope(scope: string) {
return (req: Request, res: Response, next: NextFunction): void => {
if (!req.user?.scopes.includes(scope)) {
res.status(403).json({
error: {
code: "insufficient_permissions",
message: `This action requires the '${scope}' permission.`,
details: [],
},
});
return;
}
next();
};
}

// Usage in routes
import { Router } from "express";
const router = Router();

router.get(
"/v1/tasks",
requireAuth,
requireScope("tasks:read"),
listTasksHandler,
);

router.post(
"/v1/tasks",
requireAuth,
requireScope("tasks:write"),
createTaskHandler,
);

router.delete(
"/v1/tasks/:taskId",
requireAuth,
requireScope("tasks:write"),
deleteTaskHandler,
);

The Refresh Token Pattern

Access tokens should be short-lived — 15 minutes is the standard. A short lifespan limits the damage if a token is stolen: the attacker's window is brief.

But 15-minute tokens would force users to log in every 15 minutes, which is unacceptable. Refresh tokens solve this: a long-lived token (days or weeks) that can be exchanged for a new access token without requiring the user's password again.

┌─────────────────────────────────────────────────────────────────┐
│ Token Lifecycle │
│ │
│ POST /v1/auth/login │
│ → access_token (15 min) + refresh_token (30 days) │
│ │
│ [Client uses access_token for 15 minutes] │
│ │
│ Access token expires → client gets 401 with code token_expired │
│ │
│ POST /v1/auth/refresh │
│ Body: { "refreshToken": "..." } │
│ → new access_token (15 min) + new refresh_token (30 days) │
│ [Old refresh token is rotated — invalidated immediately] │
│ │
│ [Client continues with new access_token] │
│ │
│ POST /v1/auth/logout │
│ → refresh_token is revoked in the database │
└─────────────────────────────────────────────────────────────────┘
import crypto from "crypto";
import { db } from "./database";

interface RefreshToken {
id: string;
tokenHash: string;
userId: string;
expiresAt: Date;
revokedAt: Date | null;
replacedByTokenId: string | null; // For rotation tracking
}

// Issue a new refresh token
async function issueRefreshToken(userId: string): Promise<string> {
const raw = crypto.randomBytes(64).toString("base64url");
const hash = crypto.createHash("sha256").update(raw).digest("hex");

await db.refreshTokens.create({
tokenHash: hash,
userId,
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days
});

return raw;
}

// Exchange refresh token for new tokens (with rotation)
async function refreshAccessToken(rawRefreshToken: string): Promise<{
accessToken: string;
refreshToken: string;
}> {
const hash = crypto
.createHash("sha256")
.update(rawRefreshToken)
.digest("hex");

const stored = await db.refreshTokens.findByHash(hash);

// Token not found, already used, or expired
if (!stored || stored.revokedAt !== null || stored.expiresAt < new Date()) {
// Refresh token reuse detected — possible token theft
// Revoke all tokens for this user as a precaution
if (stored) {
await db.refreshTokens.revokeAllForUser(stored.userId);
}
throw new Error("invalid_refresh_token");
}

// Rotate: revoke old token, issue new one
const newRefreshToken = await issueRefreshToken(stored.userId);
await db.refreshTokens.revoke(stored.id);

const accessToken = signTokenRS256(stored.userId, [
"tasks:read",
"tasks:write",
]);

return { accessToken, refreshToken: newRefreshToken };
}

Key security properties of this implementation:

  • Refresh tokens are hashed before storage — a database breach doesn't expose raw tokens
  • Token rotation — every refresh issues a new refresh token and invalidates the old one
  • Reuse detection — if a revoked refresh token is presented, all tokens for that user are revoked (signals possible theft)

Where to Store Tokens

This is one of the most debated questions in web security:

localStorage: Easy to implement. Persists across browser tabs. Vulnerable to XSS — any injected JavaScript on your page can read localStorage and steal the token. Do not use for production.

sessionStorage: Cleared when the tab closes. Still XSS-vulnerable.

httpOnly Cookie: The browser automatically includes it in requests. JavaScript cannot read it — XSS attacks can't steal it. Requires CSRF protection (use the SameSite=Strict cookie attribute). This is the recommended approach for web apps.

In-memory (JavaScript variable): Safest against XSS — if the page refreshes, the user logs in again. Appropriate for high-security contexts. Combine with a short-lived httpOnly cookie holding only the refresh token.

Mobile apps: Secure storage (iOS Keychain, Android Keystore). Never in plain text in shared preferences.


Pattern 4: OAuth 2.0

OAuth 2.0 is a delegation framework — it lets users grant third-party applications limited access to their accounts without sharing their passwords.

The classic example: a calendar app wants to read your TaskFlow tasks to show them alongside your meetings. You don't want to give the calendar app your TaskFlow password. OAuth 2.0 lets you authorize the calendar app to read your tasks, for a limited time, with a specific scope — without ever sharing your password.

OAuth 2.0 is defined in RFC 6749. It defines four grant types (flows) for different client scenarios.

The Four Grant Types

Authorization Code Flow

The standard flow for web applications with a server-side backend.

Diagram: Authorization Code Flow. The user approves access in the TaskFlow UI; TaskFlow issues a short-lived auth code to the third-party app; the app exchanges the code (server-side, with its client secret) for access and refresh tokens.

The auth code is short-lived (60 seconds) and single-use. The actual token exchange happens server-to-server — the client_secret never touches the browser.

Authorization Code + PKCE

PKCE (Proof Key for Code Exchange, RFC 7636, pronounced "pixie") is an extension to the Authorization Code flow for clients that can't securely store a client_secret — single-page apps and mobile apps.

Without PKCE, an SPA or mobile app can't use Authorization Code flow safely because storing a client_secret in browser JavaScript or a mobile binary is insecure (anyone can extract it).

PKCE replaces the client_secret with a one-time cryptographic proof:

// Client generates a code verifier and challenge before the redirect
function generatePKCE(): { verifier: string; challenge: string } {
// Step 1: Generate a random verifier (43-128 chars)
const verifier = crypto.randomBytes(32).toString("base64url");

// Step 2: Hash it to produce the challenge
const challenge = crypto
.createHash("sha256")
.update(verifier)
.digest("base64url");

return { verifier, challenge };
}

// Usage:
const { verifier, challenge } = generatePKCE();

// Send challenge in the authorization redirect
// GET /oauth/authorize?
// client_id=app_123
// &code_challenge=<challenge>
// &code_challenge_method=S256
// &scope=tasks:read
// &redirect_uri=https://app.example.com/callback

// Store verifier locally (in memory or sessionStorage)

// After redirect back with auth code, send verifier in token exchange
// POST /oauth/token
// { code: "AUTH_CODE", code_verifier: "<verifier>", client_id: "app_123" }
// Server hashes verifier → must match original challenge

The server hashes the verifier at token exchange time and compares it to the challenge sent during authorization. An attacker who intercepts the auth code can't exchange it without the verifier — which only the legitimate client has.

Use Authorization Code + PKCE for: Single-page apps, mobile apps, desktop apps.

Client Credentials Flow

No user involved — a backend service authenticates directly as itself.

POST /oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials
&client_id=svc_notification_service
&client_secret=cs_live_8kJd92nAm4p
&scope=tasks:read

→ 200 OK
{
"access_token": "eyJhbGci...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "tasks:read"
}

No user consent screen, no redirect. The service presents its credentials directly and gets an access token.

Use Client Credentials for: Service-to-service communication, background jobs, internal microservices.

Resource Owner Password Credentials (Avoid)

The client collects the user's username and password directly and sends them to the authorization server.

POST /oauth/token
grant_type=password
&username=alex@example.com
&password=hunter2
&client_id=app_123

This flow defeats the core purpose of OAuth — the user's password goes through the third-party app, which is exactly what OAuth was designed to prevent. It also breaks with modern auth providers (SSO, passkeys, MFA) that don't involve passwords at all.

Avoid for new systems. It exists in the spec for legacy migration scenarios. Use Authorization Code + PKCE instead.

TaskFlow's OAuth Implementation

TaskFlow implements OAuth 2.0 to allow third-party apps to integrate:

// Authorization endpoint — redirects user to consent screen
// GET /oauth/authorize

interface AuthorizeParams {
client_id: string;
redirect_uri: string;
response_type: "code";
scope: string; // Space-separated: "tasks:read tasks:write"
state: string; // CSRF protection — client generates, server echoes
code_challenge: string; // PKCE
code_challenge_method: "S256";
}

// Token endpoint — exchanges code for tokens
// POST /oauth/token

interface TokenRequest {
grant_type: "authorization_code" | "refresh_token" | "client_credentials";
// For authorization_code:
code?: string;
redirect_uri?: string;
client_id?: string;
code_verifier?: string; // PKCE verifier
// For refresh_token:
refresh_token?: string;
// For client_credentials:
client_secret?: string;
scope?: string;
}

// Defined scopes for TaskFlow's OAuth
const TASKFLOW_SCOPES = {
"tasks:read": "View your tasks and task lists",
"tasks:write": "Create, edit, and delete your tasks",
"projects:read": "View your projects",
"projects:write": "Create and edit your projects",
"profile:read": "View your profile information",
} as const;

Applying Security to TaskFlow's Endpoints

Now let's design the complete security model for every TaskFlow endpoint.

Authentication Endpoints (No Auth Required)

These endpoints are the entry point to getting credentials — they can't require credentials themselves:

POST /v1/auth/login        — Exchange email/password for tokens (security: [])
POST /v1/auth/refresh — Exchange refresh token for new tokens (security: [])
POST /v1/auth/logout — Revoke refresh token (security: [])
POST /v1/users — Register new account (security: [])
GET /v1/health — Health check (security: [])

User Endpoints

GET    /v1/users/{userId}  — Auth required; any authenticated user can view profiles
PATCH /v1/users/{userId} — Auth required; user can only edit their own profile
DELETE /v1/users/{userId} — Auth required; user can only delete their own account

Authorization logic for user endpoints:

async function updateUser(req: Request, res: Response) {
// requireAuth middleware has already run — req.user is populated

// Authorization check: can only update your own profile
if (req.user!.id !== req.params.userId) {
return res.status(403).json({
error: {
code: "insufficient_permissions",
message: "You can only update your own profile.",
details: [],
},
});
}

// Proceed with update...
}

Task Endpoints

GET    /v1/tasks             — Auth required; returns only tasks visible to user
POST /v1/tasks — Auth required; creates task owned by current user
GET /v1/tasks/{taskId} — Auth required; 404 if task doesn't exist or user can't see it
PATCH /v1/tasks/{taskId} — Auth required; must be task owner or project member
DELETE /v1/tasks/{taskId} — Auth required; must be task owner

Note the deliberate choice for GET /v1/tasks/{taskId}: returning 404 (not 403) when a user requests a task they can't see. This prevents resource enumeration — an attacker can't determine whether a task ID exists by distinguishing 403 (exists, can't see) from 404 (doesn't exist).

async function getTask(req: Request, res: Response) {
const task = await db.tasks.findById(req.params.taskId);

// Return 404 whether the task doesn't exist OR user can't see it
// This prevents leaking that the task ID is valid
if (!task || !canUserViewTask(req.user!.id, task)) {
return res.status(404).json({
error: {
code: "not_found",
message: `Task '${req.params.taskId}' does not exist.`,
details: [],
},
});
}

return res.json({ data: serializeTaskV1(task) });
}

function canUserViewTask(userId: string, task: TaskRecord): boolean {
// User can see a task if:
// 1. They created it
// 2. They're assigned to it
// 3. They're a member of the task's project
return (
task.creatorId === userId ||
task.assigneeId === userId ||
isProjectMember(userId, task.projectId)
);
}

Comment Endpoints

GET    /v1/tasks/{taskId}/comments               — Auth required; task must be visible
POST /v1/tasks/{taskId}/comments — Auth required; task must be visible
PATCH /v1/tasks/{taskId}/comments/{commentId} — Auth required; must be comment author
DELETE /v1/tasks/{taskId}/comments/{commentId} — Auth required; must be comment author

Completing the OpenAPI Security Scheme

Let's complete the securitySchemes section from Article 6 with everything we've now designed:

components:
securitySchemes:
BearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
description: |
JWT Bearer token obtained via `POST /v1/auth/login`.

Access tokens expire after 15 minutes.
Use `POST /v1/auth/refresh` with your refresh token to obtain a new access token.

Example:
```
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
```

ApiKeyAuth:
type: apiKey
in: header
name: Authorization
description: |
API key for server-to-server integrations. Prefix with "ApiKey ".

Example:
```
Authorization: ApiKey tf_live_8kJd92nAm4pQr5sUv9wXy1zA3bC6dE
```

Obtain an API key from the TaskFlow developer dashboard.

OAuth2:
type: oauth2
description: OAuth 2.0 for third-party integrations
flows:
authorizationCode:
authorizationUrl: https://api.taskflow.com/oauth/authorize
tokenUrl: https://api.taskflow.com/oauth/token
refreshUrl: https://api.taskflow.com/oauth/token
scopes:
tasks:read: View tasks and task lists
tasks:write: Create, edit, and delete tasks
projects:read: View projects
projects:write: Create and edit projects
profile:read: View profile information
clientCredentials:
tokenUrl: https://api.taskflow.com/oauth/token
scopes:
tasks:read: View tasks and task lists
tasks:write: Create, edit, and delete tasks

# Global security — all endpoints require BearerAuth by default
security:
- BearerAuth: []

paths:
# Auth endpoints override global security
/v1/auth/login:
post:
operationId: login
summary: Log in
security: [] # No auth required — this IS the auth endpoint
tags: [Auth]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [email, password]
properties:
email:
type: string
format: email
example: alex@example.com
password:
type: string
format: password
minLength: 8
example: "••••••••"
responses:
"200":
description: Login successful
content:
application/json:
schema:
type: object
properties:
data:
type: object
properties:
accessToken:
type: string
description: JWT access token (15 minute expiry)
refreshToken:
type: string
description: Refresh token (30 day expiry)
expiresIn:
type: integer
description: Access token lifetime in seconds
example: 900
"401":
$ref: "#/components/responses/Unauthorized"

/v1/auth/refresh:
post:
operationId: refreshToken
summary: Refresh access token
security: [] # Auth comes from the refresh token in the body
tags: [Auth]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [refreshToken]
properties:
refreshToken:
type: string
description: The refresh token from a previous login or refresh
responses:
"200":
description: New access and refresh tokens issued
content:
application/json:
schema:
type: object
properties:
data:
type: object
properties:
accessToken:
type: string
refreshToken:
type: string
expiresIn:
type: integer
example: 900
"401":
$ref: "#/components/responses/Unauthorized"

/v1/tasks:
get:
operationId: listTasks
summary: List tasks
# Inherits global BearerAuth
# Also accepts ApiKeyAuth for integrations
security:
- BearerAuth: []
- ApiKeyAuth: []
- OAuth2: [tasks:read]
# ... rest of operation definition

Common Misconceptions

❌ Misconception: JWTs are encrypted and private

Reality: JWTs are base64url-encoded, not encrypted. Anyone who holds a JWT can decode and read the payload — atob(token.split('.')[1]) in any browser console.

The signature prevents tampering, but the payload is readable. Never store sensitive information (passwords, credit card numbers, social security numbers) in a JWT payload.

What's safe in a JWT: User ID, scopes, email (if not sensitive in context), expiry. What's not safe: Passwords, secrets, full user profiles that shouldn't be visible to the client.

❌ Misconception: Longer JWT expiry is more convenient and equally safe

Reality: Token expiry is your blast radius control. A stolen 15-minute token is useful to an attacker for 15 minutes at most. A stolen 7-day token gives the attacker 7 days of access — potentially long enough to exfiltrate everything.

Short access tokens + refresh tokens is the correct architecture precisely because it limits the damage from token theft without requiring frequent re-authentication.

❌ Misconception: OAuth 2.0 is an authentication protocol

Reality: OAuth 2.0 is an authorization framework — it's designed to grant access, not to verify identity. The spec explicitly does not define a way to get user profile information from an access token.

OpenID Connect (OIDC) is the authentication layer built on top of OAuth 2.0. It adds an id_token (always a JWT) containing the user's identity, and a /userinfo endpoint. If you need to know who the user is (not just that they authorized access), you need OIDC — not plain OAuth 2.0.

❌ Misconception: HTTPS makes token storage in localStorage safe

Reality: HTTPS protects tokens in transit. It does nothing to protect tokens once they're stored client-side. XSS (Cross-Site Scripting) attacks don't intercept tokens in transit — they run JavaScript on the page that reads localStorage directly. HTTPS doesn't prevent that.

httpOnly cookies are the correct defense: they're inaccessible to JavaScript regardless of whether the page is compromised.


Troubleshooting Common Issues

Problem: JWT validation passes but the user doesn't have the right permissions

Symptoms: A request succeeds authentication (requireAuth passes) but the handler returns 403 unexpectedly.

Diagnostic steps:

// Decode the token payload without verifying (for debugging only)
const payload = JSON.parse(
Buffer.from(token.split(".")[1], "base64url").toString(),
);
console.log("Token scopes:", payload.scopes);
console.log("Token subject:", payload.sub);
console.log("Token expiry:", new Date(payload.exp * 1000));

Check:

  1. Does the token's sub match the expected user ID?
  2. Does the token's scopes array include the required scope?
  3. Was the token issued with the correct scopes at login time?

Problem: Clients are getting 401 on every request after a short time

Symptoms: API calls work immediately after login, then fail with 401 token_expired after 15 minutes.

Cause: The client isn't implementing the token refresh flow. When it receives 401 with code: "token_expired", it should automatically call POST /v1/auth/refresh and retry the original request with the new token.

Client-side refresh implementation:

async function fetchWithAuth(url: string, options: RequestInit = {}) {
// Attempt the request
let response = await fetch(url, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${getAccessToken()}`,
},
});

// If token expired, refresh and retry once
if (response.status === 401) {
const body = await response.json();
if (body.error?.code === "token_expired") {
const refreshed = await refreshAccessToken();
if (refreshed) {
// Retry with new token
response = await fetch(url, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${refreshed.accessToken}`,
},
});
}
}
}

return response;
}

Problem: Users are seeing each other's resources

Symptoms: User A can access tasks created by user B — authorization checks aren't working.

Diagnostic approach:

  1. Verify req.user.id is correctly populated by the auth middleware
  2. Check every database query for a missing WHERE userId = ? clause
  3. Add integration tests that assert cross-user resource isolation:
// Integration test — user isolation
test("user A cannot see user B's tasks", async () => {
const taskB = await createTask({ creatorId: "user_b" });

const response = await request(app)
.get(`/v1/tasks/${taskB.id}`)
.set("Authorization", `Bearer ${userAToken}`);

// Should be 404 (not 403 — don't leak that the resource exists)
expect(response.status).toBe(404);
});

Check Your Understanding

Quick Quiz

  1. A user provides a valid JWT but requests a task they don't have permission to see. What status code should the server return, and why?

    Show Answer

    404 Not Found — not 403 Forbidden.

    Returning 403 would confirm to the caller that the task ID is valid (the resource exists, they just can't see it). This is called resource enumeration — an attacker could iterate through task IDs to discover which ones exist.

    Returning 404 treats unauthorized resources identically to non-existent ones, preventing information leakage.

    Exception: if the resource is not sensitive (e.g., public profiles), 403 is appropriate because existence isn't a secret.

  2. What's the difference between the Authorization Code flow and Authorization Code + PKCE? When do you use each?

    Show Answer

    Authorization Code flow uses a client_secret to authenticate the client during token exchange. The secret is safe because the exchange happens server-side — the browser never sees it.

    Authorization Code + PKCE replaces the client_secret with a one-time cryptographic proof (code verifier + challenge). It's used when there's no safe place to store a client_secret — browser-based SPAs (JavaScript can be read by anyone) and mobile apps (binary can be decompiled).

    Use Authorization Code (without PKCE) for: server-side web apps with a secure backend. Use Authorization Code + PKCE for: SPAs, mobile apps, desktop apps.

  3. Why should refresh tokens be rotated on every use?

    Show Answer

    Rotation limits the window of opportunity if a refresh token is stolen.

    Without rotation: a stolen refresh token grants indefinite access until the user explicitly logs out.

    With rotation: each use of a refresh token invalidates the old one and issues a new one. If a stolen token is used, the legitimate client will find its copy revoked — triggering a re-authentication. The reuse detection pattern (revoking all tokens when a revoked token is presented) further limits attacker access.

  4. Is it safe to store a user's role (e.g., "role": "admin") in a JWT payload?

    Show Answer

    Safe from tampering — the JWT signature prevents modification.

    Safe in terms of privacy — roles are not typically sensitive data.

    Potentially stale — roles stored in JWTs reflect the role at token-issuance time. If a user's role changes (e.g., an admin is demoted), their existing access tokens still carry the old role until they expire. Short access token lifetimes (15 min) limit this window. For critical role changes (especially privilege removal), consider maintaining a token denylist or using short-lived tokens only.

Hands-On Exercise

Challenge: Design the complete security model for a new TaskFlow endpoint: DELETE /v1/projects/{projectId}.

Consider:

  1. What authentication is required?
  2. What authorization logic should run?
  3. What should the server return if the user isn't authenticated?
  4. What should the server return if the user is authenticated but not the project owner?
  5. What should the server return if the project doesn't exist?
  6. Write the OpenAPI security definition for this endpoint.
Show Answer

Authentication: Bearer token required (or API key for integrations).

Authorization logic: Only the project creator (or a designated project admin) can delete a project. Regular members cannot.

Response matrix:

  • No credentials → 401 with code: "authentication_required"
  • Valid credentials, not project owner → 403 with code: "insufficient_permissions" (project ownership is known to the requester — 403 is appropriate here, unlike task visibility)
  • Valid credentials, project doesn't exist → 404 with code: "not_found"
  • Valid credentials, is project owner → 204 No Content

Handler:

async function deleteProject(req: Request, res: Response) {
// requireAuth already ran — req.user is populated
const project = await db.projects.findById(req.params.projectId);

if (!project) {
return res.status(404).json({
error: {
code: "not_found",
message: `Project '${req.params.projectId}' does not exist.`,
details: [],
},
});
}

if (project.creatorId !== req.user!.id) {
return res.status(403).json({
error: {
code: "insufficient_permissions",
message: "Only the project creator can delete a project.",
details: [],
},
});
}

await db.projects.delete(project.id);
return res.status(204).send();
}

OpenAPI definition:

/v1/projects/{projectId}:
delete:
operationId: deleteProject
summary: Delete a project
description: |
Permanently deletes a project and all its tasks and comments.
Only the project creator can perform this action.
tags:
- Projects
security:
- BearerAuth: []
- ApiKeyAuth: []
parameters:
- $ref: "#/components/parameters/ProjectIdParam"
responses:
"204":
description: Project deleted successfully
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"

Summary: Key Takeaways

  • Authentication and authorization are distinct. Authentication verifies identity (401 when missing). Authorization checks permissions (403 when present but insufficient). Never conflate them.

  • Four patterns, four use cases: API keys for server-to-server integrations; HTTP Basic Auth for simple internal tooling (HTTPS only); Bearer tokens / JWTs for user-facing web and mobile; OAuth 2.0 for third-party delegated access.

  • JWTs are signed, not encrypted. The payload is readable by anyone with the token. The signature prevents tampering. Never put sensitive secrets in a JWT payload.

  • Short access tokens + rotating refresh tokens is the correct architecture. 15-minute access token expiry limits blast radius. Refresh token rotation detects theft. Reuse detection revokes all tokens when a compromised token appears.

  • OAuth 2.0 grant type selection: Authorization Code for server-side apps; Authorization Code + PKCE for SPAs and mobile; Client Credentials for service-to-service; avoid Resource Owner Password for new systems.

  • Resource enumeration protection: Return 404 (not 403) when an authenticated user requests a resource they can't see — to avoid confirming the resource's existence.

  • TaskFlow uses RS256-signed JWTs for user authentication, API keys for developer integrations, and OAuth 2.0 (Authorization Code + PKCE, Client Credentials) for third-party access.


What's Next?

You now have the complete TaskFlow API design: resources, methods, payloads, versioning, a formal OpenAPI spec, and a full security model.

The final article is API Design Review: Common Mistakes and How to Fix Them (coming soon) — a capstone where we audit a deliberately broken version of the TaskFlow API, work through the ten most common REST API design mistakes with real-world examples, and produce a final production-ready spec with a checklist you can apply to any API you build.


References