6 min read
On this page

Implementing Auth Middleware

Auth as a Cross-Cutting Concern

Authentication and authorization do not belong in individual route handlers. Every endpoint needs the same checks: extract the token, validate it, identify the caller, and decide whether they have permission. Scattering this logic across handlers leads to inconsistent enforcement, forgotten checks, and security holes.

Middleware intercepts requests before they reach the handler. A single authentication middleware ensures every protected endpoint goes through the same validation path.

Request -> Auth Middleware -> Route Handler -> Response
                |
                v
          401/403 Response

Extracting the Token

The standard location for bearer tokens is the Authorization header.

GET /api/v1/users/me
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...

The middleware parses this header and extracts the token:

1. Check for Authorization header
2. Verify it starts with "Bearer "
3. Extract the token string after "Bearer "
4. If header is missing or malformed, return 401

Some APIs also accept tokens in query parameters for specific use cases (like WebSocket connections or file downloads where headers are inconvenient), but the Authorization header should be the primary mechanism.

{
  "error": {
    "type": "authentication_error",
    "message": "Missing or malformed Authorization header. Expected: Bearer <token>"
  }
}

API Key Extraction

For API key authentication, common locations include:

X-API-Key: sk_live_4eC39HqLyjWDarjtT1zdp7dc

Or in the Authorization header with a different scheme:

Authorization: ApiKey sk_live_4eC39HqLyjWDarjtT1zdp7dc

Stripe uses the Authorization header with Basic auth, where the API key is the username and the password is empty:

Authorization: Basic c2tfbGl2ZV80ZUMzOUhxTHlqV0Rhcmp0VDF6ZHA3ZGM6

Validating the Token

JWT Validation Steps

JWT validation is a precise, ordered process. Every step must pass.

Step 1: Decode the header (without verifying). Read the alg (algorithm) and kid (key ID) claims to determine which key to use for verification.

{
  "alg": "RS256",
  "kid": "key_2024_03",
  "typ": "JWT"
}

Step 2: Verify the signature. Using the public key identified by kid, verify that the token was signed by your auth server. If this fails, the token is forged or tampered with.

Step 3: Check expiration. The exp claim is a Unix timestamp. If the current time is past exp, the token is expired. Allow a small clock skew (typically 30-60 seconds) to account for time differences between servers.

{
  "exp": 1711036800,
  "iat": 1711033200,
  "nbf": 1711033200
}

Step 4: Verify the audience. The aud claim must match your API's identifier. A token issued for https://api.example.com should not be accepted by https://other-api.example.com. This prevents token confusion attacks.

Step 5: Verify the issuer. The iss claim must match your expected auth server. This ensures the token came from a trusted source.

Step 6: Check the nbf (not before) claim. If present, the token is not valid before this timestamp.

API Key Validation

For API keys, validation means a database lookup:

1. Hash the provided key (never store keys in plaintext)
2. Look up the hash in the database
3. Check if the key is active (not revoked, not expired)
4. Load the associated client and permissions

Setting User Context

After successful validation, the middleware attaches the authenticated identity to the request context so downstream handlers can use it without re-parsing the token.

The context typically includes:

{
  "user_id": "user_123",
  "client_id": "app_456",
  "scopes": ["read:users", "write:orders"],
  "roles": ["admin"],
  "token_expires_at": "2024-03-22T12:00:00Z"
}

Handlers then access this context to make authorization decisions:

GET /api/v1/users/me

Handler reads user_id from context -> returns that user's profile
No need to re-parse the token or hit the auth database

This pattern is universal. Express.js uses req.user, Django uses request.user, Go passes it through context.Context, and Spring Security uses SecurityContextHolder.

The 401 vs 403 Distinction

These two status codes are frequently confused, but they have precise, distinct meanings.

401 Unauthorized (Really: Unauthenticated)

401 means "I do not know who you are." The server cannot identify the caller. This happens when:

  • No token is provided
  • The token is malformed
  • The token signature is invalid
  • The token has expired
{
  "error": {
    "type": "authentication_error",
    "status": 401,
    "message": "Invalid or expired token. Please re-authenticate."
  }
}

The response should include a WWW-Authenticate header indicating the expected authentication scheme:

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="api", error="invalid_token", error_description="The token has expired"

403 Forbidden (Authorization Failed)

403 means "I know exactly who you are, but you do not have permission to do this." The authentication succeeded, but the authorization check failed. This happens when:

  • The user's role lacks the required permission
  • The token's scopes do not include the needed scope
  • The user is trying to access another user's private resource
{
  "error": {
    "type": "authorization_error",
    "status": 403,
    "message": "Insufficient permissions. Required scope: write:users"
  }
}

When to Use Each

A practical rule: if re-authenticating (logging in again, getting a new token) could fix the problem, use 401. If the user genuinely lacks permission and no amount of re-authentication will help, use 403.

GitHub returns 404 instead of 403 for private repositories the user cannot access. This prevents leaking the existence of private resources. This is a valid security pattern — use 404 when even acknowledging the resource's existence is a security concern.

Handling Expired Tokens Gracefully

Token expiration is not an error condition — it is expected behavior. A well-designed API helps clients handle it smoothly.

The Refresh Flow

When the access token expires, the client uses a refresh token to obtain a new one without requiring the user to log in again.

POST /oauth/token
Content-Type: application/json

{
  "grant_type": "refresh_token",
  "refresh_token": "dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4...",
  "client_id": "app_456"
}
{
  "access_token": "eyJhbGciOiJSUzI1NiIs...",
  "refresh_token": "bmV3IHJlZnJlc2ggdG9rZW4...",
  "token_type": "Bearer",
  "expires_in": 900
}

Communicating Expiration

The 401 response for an expired token should be distinguishable from other authentication failures so the client knows to attempt a refresh rather than a full re-authentication.

{
  "error": {
    "type": "token_expired",
    "status": 401,
    "message": "Access token has expired. Use your refresh token to obtain a new access token."
  }
}

Google's APIs return a specific error code in the WWW-Authenticate header:

WWW-Authenticate: Bearer error="invalid_token", error_description="The access token expired"

Proactive Expiration Handling

Include the token's expiration time in the authentication response. Clients can proactively refresh tokens before they expire, avoiding failed requests entirely.

{
  "access_token": "eyJhbGciOi...",
  "expires_in": 900,
  "expires_at": "2024-03-22T12:15:00Z"
}

Middleware Ordering

Auth middleware must run in the correct order relative to other middleware:

1. Request logging (log all requests, including auth failures)
2. CORS handling (preflight requests must succeed without auth)
3. Rate limiting (limit by IP before auth to prevent brute force)
4. Authentication (identify the caller)
5. Authorization (check permissions)
6. Route handler

CORS preflight requests (OPTIONS) must bypass authentication entirely. Rate limiting before authentication prevents credential stuffing attacks from consuming resources.

Common Pitfalls

Implementing auth per-handler instead of as middleware. One forgotten handler is an open door. Use middleware for authentication and let handlers assume the user is already identified.

Returning 403 when you mean 401. If the token is missing or invalid, the client needs to know it should re-authenticate (401), not that it lacks permissions (403). Getting this wrong breaks automatic token refresh in client SDKs.

Not validating the aud claim. Without audience validation, a token issued for one API can be used against another API in the same organization. This is a token confusion attack.

Logging full tokens. Tokens in logs are tokens that can be stolen. Log a token identifier or a hash, never the full token string.

Blocking on token validation for every request. Cache validated tokens (with short TTL) or use JWTs to avoid hitting the auth database on every request. A slow auth check slows every endpoint.

Skipping auth on internal endpoints. Internal endpoints exposed to the network are not truly internal. Validate authentication on every endpoint, or use network-level isolation (service mesh, VPC) as defense in depth.

Key Takeaways

  • Implement authentication as middleware, not per-handler; a single missed handler is a security vulnerability.
  • Follow the full JWT validation chain: signature, expiration, audience, issuer, and not-before claims.
  • Use 401 for "I do not know who you are" and 403 for "I know who you are, but you cannot do this."
  • Handle token expiration gracefully by distinguishing expired tokens from invalid tokens so clients can refresh automatically.
  • Set user context in middleware so downstream handlers have clean access to the authenticated identity without re-parsing tokens.