Siddhant Deval
Siddhant Deval
backend19 min read

Authentication Architecture: JWT Self-Verification, OIDC Identity & RBAC at Scale

Authentication in microservices is not a single service problem — it is an identity propagation architecture problem. Stateless JWT self-verification removes the auth server as a synchronous bottleneck, OIDC standardizes the identity layer for SSO federation, and RBAC role claims enforced at the API Gateway perimeter prevent authorization logic from leaking into every downstream service.

Authentication Architecture: JWT Self-Verification, OIDC Identity & RBAC at Scale

Senior engineers don't just wire services together — they design the boundary: the contract, the trust model, the failure envelope, and the signal pipeline that proves it's working. Authentication in microservices is where the trust model is most commonly designed incorrectly. Teams extract auth logic into a dedicated service, then call that service on every request from every downstream service — replacing a stateless JWT verification (a cryptographic operation taking microseconds) with a synchronous network call (taking 20–200ms) that becomes a single point of failure for the entire system. The OIDC standard exists specifically to solve this: identity is established once, propagated as a signed, self-verifiable token, and enforced at the boundary rather than in every room behind it.

Architectural Note

Series positioning: This is Part 5 of the API Architecture & System Resilience series. It builds on the API Gateway from Part 2 (the enforcement point) and the mTLS workload identity from Part 3 (the service-to-service layer). The browser-side OAuth2 pattern (PKCE, httpOnly cookies, token-mediating BFF) is covered in the Frontend Platform series — this article focuses on the microservice-side identity architecture.


1. JWT Anatomy & the aud Validation Gap

1.1 The Three-Part Structure

TYPESCRIPT
// A JWT is: base64url(header) . base64url(payload) . signature
// Header:
{
  "alg": "RS256",   // RSA signature — asymmetric (public key distributable via JWKS)
  "typ": "JWT",
  "kid": "key-2026-09"  // Key ID — used to select the correct public key from JWKS
}

// Payload (claims):
{
  "sub": "usr_abc123",          // Subject — stable user identifier
  "iss": "https://auth.example.com",  // Issuer — must match expected auth server
  "aud": ["order-service", "payment-service"],  // Audience — which services may accept this token
  "exp": 1757239800,            // Expiration — Unix timestamp
  "nbf": 1757239200,            // Not Before — token not valid before this time
  "iat": 1757239200,            // Issued At
  "jti": "tok_xyz987",          // JWT ID — unique identifier for this token (revocation)
  "roles": ["customer"],        // Custom claim — propagated via gateway
  "sessionId": "sess_456"       // Session binding claim
}

// Signature: RSA-SHA256 of (base64url(header) + "." + base64url(payload)) using private key
// Verified by any holder of the public key — no call to auth server required

1.2 The Missing aud Validation

TYPESCRIPT
// ❌ Missing audience validation — any JWT from this issuer is accepted
import jwt from 'jsonwebtoken'
import jwksClient from 'jwks-rsa'

const client = jwksClient({ jwksUri: 'https://auth.example.com/.well-known/jwks.json' })

async function verifyToken(token: string) {
  const decoded = jwt.verify(token, getKey, { algorithms: ['RS256'] })
  // Checks: signature ✅, expiry ✅, not-before ✅
  // Missing: aud check ❌
  return decoded
}
// Attack: A JWT issued for the payment-service (aud: ["payment-service"])
// is accepted by the order-service because aud is never validated.
// An attacker who steals a payment-service token can call order-service endpoints.

// ✅ Audience validation — reject tokens not intended for this service
async function verifyToken(token: string, expectedAudience: string) {
  const decoded = jwt.verify(token, getKey, {
    algorithms: ['RS256'],
    audience: expectedAudience,  // Rejects if aud claim does not include this string
    issuer: 'https://auth.example.com',
    clockTolerance: 30,  // 30-second clock skew tolerance for distributed systems
  })
  return decoded
}

// order-service:
const claims = await verifyToken(token, 'order-service')
// payment-service:
const claims = await verifyToken(token, 'payment-service')

2. Stateless JWKS-Based Verification

2.1 The Auth Server Bottleneck

TYPESCRIPT
// ❌ Token introspection on every request — auth server becomes a synchronous bottleneck
async function verifyToken(token: string): Promise<boolean> {
  const response = await fetch('https://auth.example.com/introspect', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: `token=${token}&client_id=${CLIENT_ID}&client_secret=${CLIENT_SECRET}`,
  })
  const result = await response.json()
  return result.active
}
// Problems:
// - Every request adds 20–200ms network latency to the auth server
// - Auth server outage = all services reject all requests = total system failure
// - Auth server becomes a synchronous dependency at 100% request rate

2.2 JWKS Public Key Caching

TYPESCRIPT
// ✅ JWKS-based verification — cryptographic verification without auth server calls
import jwksClient from 'jwks-rsa'
import jwt from 'jsonwebtoken'

const jwks = jwksClient({
  jwksUri: 'https://auth.example.com/.well-known/jwks.json',
  cache: true,              // Cache public keys in memory
  cacheMaxEntries: 10,      // Cache up to 10 keys (for rotation overlap)
  cacheMaxAge: 600_000,     // 10-minute cache TTL — keys rotate slowly (daily/weekly)
  rateLimit: true,          // Prevent thundering herd on cache miss
  jwksRequestsPerMinute: 10,
})

function getKey(header: jwt.JwtHeader, callback: jwt.SigningKeyCallback) {
  jwks.getSigningKey(header.kid!, (err, key) => {
    if (err) return callback(err)
    callback(null, key!.getPublicKey())
  })
}

// Verification is now a local cryptographic operation — no network call
// Auth server can be down for 10 minutes without affecting running services
const claims = await new Promise<jwt.JwtPayload>((resolve, reject) => {
  jwt.verify(token, getKey, {
    algorithms: ['RS256'],  // Never allow HS256 — symmetric keys cannot be distributed safely
    audience: process.env.SERVICE_NAME,
    issuer: process.env.AUTH_SERVER_URL,
    clockTolerance: 30,
  }, (err, decoded) => {
    if (err) reject(err)
    else resolve(decoded as jwt.JwtPayload)
  })
})

3. Token Rotation & Redis Blocklist Revocation

TYPESCRIPT
// ✅ Short-lived access tokens + Redis blocklist for immediate revocation
// Access token TTL: 15 minutes
// Refresh token TTL: 7 days (rotated on each use)

// Auth server issues tokens:
const accessToken = jwt.sign(
  { sub: userId, roles, jti: crypto.randomUUID() },  // jti = unique token ID
  privateKey,
  { algorithm: 'RS256', expiresIn: '15m', audience: ['order-service', 'payment-service'] }
)

// Revocation — add token's jti to Redis blocklist with TTL = remaining validity
async function revokeToken(jti: string, remainingTtlSeconds: number) {
  await redis.set(`revoked:${jti}`, '1', { EX: remainingTtlSeconds })
  // After 15 minutes the token expires naturally — Redis key self-deletes (no cleanup needed)
}

// Service-side verification — check blocklist after signature validation
async function verifyToken(token: string): Promise<jwt.JwtPayload> {
  const claims = await verifyJwtSignature(token)  // Cryptographic verification (no network)

  const isRevoked = await redis.exists(`revoked:${claims.jti}`)
  if (isRevoked) throw new Error('Token has been revoked')

  return claims
  // Redis lookup: ~0.5–2ms — negligible vs the auth server introspection (20–200ms)
}
Crucial Requirement

The blocklist only needs to survive until the token's natural expiry (exp claim). Use a Redis TTL that matches the remaining token validity: TTL = exp - Date.now()/1000. This keeps the blocklist bounded in size regardless of how many tokens are revoked.


4. OIDC: The Identity Layer

4.1 ID Token vs Access Token — The Critical Distinction

TYPESCRIPT
// OAuth 2.0 Authorization Code Flow with OIDC

// Step 1: User authenticates at the Authorization Server
// Authorization Server issues TWO tokens:

// ACCESS TOKEN — For service authorization
{
  "alg": "RS256",
  "typ": "at+JWT"  // "at+" prefix distinguishes access tokens from ID tokens
}
// Payload:
{
  "sub": "usr_abc",
  "iss": "https://auth.example.com",
  "aud": ["order-service", "payment-service"],  // Services that should accept this token
  "scope": "orders:read orders:write payments:read",
  "roles": ["customer"],
  "exp": ..., "jti": ...
}

// ID TOKEN — For client identity establishment only (NEVER sent to services)
{
  "alg": "RS256",
  "typ": "JWT"
}
// Payload:
{
  "sub": "usr_abc",
  "iss": "https://auth.example.com",
  "aud": "client_app_id",  // Audience is the CLIENT APPLICATION — not any service
  "email": "user@example.com",
  "name": "Alice Smith",
  "picture": "https://...",
  "email_verified": true,
  "exp": ..., "iat": ..., "nonce": "..."
}
Performance / Safety Warning

Sending the ID token to backend services is a security design flaw. The ID token's aud claim is the client application — any service that accepts it is violating the audience constraint. The ID token contains PII (email, name, picture) that backend services should not receive unless explicitly needed. Use the access token for service calls; use the ID token only in the client to display user information.

4.2 OIDC Discovery Document

TYPESCRIPT
// OIDC Discovery — the standard metadata endpoint
// GET https://auth.example.com/.well-known/openid-configuration

{
  "issuer": "https://auth.example.com",
  "authorization_endpoint": "https://auth.example.com/authorize",
  "token_endpoint": "https://auth.example.com/token",
  "userinfo_endpoint": "https://auth.example.com/userinfo",
  "jwks_uri": "https://auth.example.com/.well-known/jwks.json",
  "response_types_supported": ["code", "token", "id_token"],
  "grant_types_supported": ["authorization_code", "refresh_token", "client_credentials"],
  "id_token_signing_alg_values_supported": ["RS256"],
  "claims_supported": ["sub", "iss", "aud", "exp", "iat", "email", "name", "roles"]
}

// JWKS endpoint — public keys for JWT verification
// GET https://auth.example.com/.well-known/jwks.json
{
  "keys": [
    {
      "kty": "RSA",
      "use": "sig",
      "kid": "key-2026-09",  // Key ID — matched by jwt header 'kid' field
      "n": "...",             // RSA modulus
      "e": "AQAB"            // RSA public exponent
    }
  ]
}

5. SSO Federation via OIDC

The key to SSO is that the user's session lives at the Authorization Server, not in any individual application. When App B redirects to the AS with prompt=none, the AS detects the existing session and issues tokens without a login page.


6. RBAC via API Gateway Headers

TYPESCRIPT
// ✅ Gateway extracts role claims from JWT and injects as HTTP headers
// (In Kong: jwt plugin + request-transformer plugin)

// After JWT validation, Kong injects:
// X-User-Id: usr_abc
// X-Roles: customer,order:read,order:write
// X-Email: user@example.com (optional — only if service needs it)

// order-service: RBAC using headers — zero JWT code in the service
app.post('/orders', (req, res, next) => {
  const roles = (req.headers['x-roles'] as string)?.split(',') ?? []

  if (!roles.includes('order:write')) {
    return res.status(403).json({
      type: 'https://api.example.com/problems/forbidden',
      title: 'Forbidden',
      status: 403,
      detail: 'The order:write role is required to create orders.',
    })
  }

  next() // Proceed to order creation handler
})
// No JWT library. No JWKS fetch. No auth server call.
// Authorization is enforced on data (headers), not auth logic.
JWT self-verification circuit mental model: API Gateway validates JWT signature against JWKS cache, checks exp and aud claims, then injects X-User-Id and X-Roles headers. Downstream services (order-service, payment-service, inventory-service) trust the gateway-injected headers as pre-verified identity, never performing their own JWT validation or auth server calls.
JWT self-verification circuit mental model: API Gateway validates JWT signature against JWKS cache, checks exp and aud claims, then injects X-User-Id and X-R…

7. ABAC with OPA for Fine-Grained Authorization

REGO
# OPA Rego policy — declarative attribute-based access control
# policies/order-access.rego

package order_access

import future.keywords.if
import future.keywords.in

# Allow order creation if user has order:write role
allow if {
    input.method == "POST"
    input.path == "/orders"
    "order:write" in input.roles
}

# Allow order retrieval only for the order's owner or admin
allow if {
    input.method == "GET"
    startswith(input.path, "/orders/")
    orderId := trim_prefix(input.path, "/orders/")

    # Either the requesting user owns the order
    input.userId == order_owner[orderId]
}

allow if {
    input.method == "GET"
    "admin" in input.roles
}
TYPESCRIPT
// OPA sidecar integration — evaluate policy before handler
async function opaMiddleware(req: Request, res: Response, next: NextFunction) {
  const opaResponse = await fetch('http://localhost:8181/v1/data/order_access/allow', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      input: {
        method: req.method,
        path: req.path,
        userId: req.headers['x-user-id'],
        roles: (req.headers['x-roles'] as string)?.split(',') ?? [],
      }
    })
  })
  const { result } = await opaResponse.json()
  if (!result) return res.status(403).json({ status: 403, title: 'Forbidden' })
  next()
}
OIDC authorization code flow trace: browser redirects to authorization server login; user authenticates; auth server issues authorization code; client exchanges code for access token plus ID token; API gateway validates access token against JWKS; gateway injects X-User-Id and X-Roles headers; microservices receive pre-verified identity without auth server calls.
OIDC authorization code flow trace: browser redirects to authorization server login; user authenticates; auth server issues authorization code; client exchan…

Summary

Concern Auth Architecture Rule
JWT verification Cryptographic JWKS verification — never token introspection per request
aud claim Mandatory validation on every service — prevents cross-service token replay
Algorithm RS256 only — HS256 shared secrets cannot be distributed safely to multiple services
JWKS caching 10-minute TTL with rate limit — prevents thundering herd on cache miss
Token revocation Redis blocklist on jti claim with TTL = remaining token validity
ID token For client only — never send to backend services; PII + wrong audience
RBAC Role claims in JWT → gateway injects X-Roles header → services apply as data
ABAC OPA Rego policies for fine-grained, attribute-based authorization without code changes

What's Next

In Part 6, we build the signal pipeline that proves the system is working — Part 6: Distributed Observability covers OpenTelemetry instrumentation, W3C traceparent propagation across every service hop, tail-based sampling, and structured JSON logging correlated by trace and span IDs so that every incident can be resolved from a single Trace ID.

Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#JWT#OIDC#OAuth 2.0#RBAC#Authentication#Microservices#Security
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.