Auth in microservices: the three patterns and which one to use

When you move from a monolith to microservices, authentication stops being a single middleware call and becomes an infrastructure problem. Every service needs to know who is making a request and whether they are authorized to do it. The naive approach of each service independently verifying tokens against a shared database creates coupling that undermines the architecture. There are three mature patterns for solving this, and the right choice depends on your team structure, latency budget, and security requirements.

Pattern 1: centralized auth service

Every incoming request passes through a dedicated authentication service before reaching any downstream service. The auth service validates the token, resolves permissions, and either forwards the request with a verified identity header or rejects it.

// API Gateway delegates token validation to auth service
app.post('/internal/verify', async (req, res) => {
  const { token, required_scopes } = req.body;

  try {
    const payload = await jwt.verify(token, process.env.JWT_PUBLIC_KEY);
    const user = await db.users.findById(payload.sub);

    if (!user || user.suspended) {
      return res.status(401).json({ valid: false });
    }

    const tokenScopes = payload.scope.split(' ');
    const hasScopes = required_scopes.every(s => tokenScopes.includes(s));
    if (!hasScopes) {
      return res.status(403).json({ valid: false, reason: 'insufficient_scope' });
    }

    res.json({
      valid: true,
      identity: {
        user_id: user.id,
        email: user.email,
        org_id: user.org_id,
        roles: user.roles,
        scopes: tokenScopes
      }
    });
  } catch {
    res.status(401).json({ valid: false });
  }
});

// Downstream service trusts the identity header set by the gateway
function requireAuth(req, res, next) {
  const raw = req.headers['x-verified-identity'];
  if (!raw) return res.status(401).json({ error: 'unauthenticated' });
  req.user = JSON.parse(Buffer.from(raw, 'base64').toString('utf8'));
  next();
}

The advantage here is that all auth logic lives in one place. Rotating a signing key, changing a token format, or adding a new claim requires a change to one service, not dozens. The disadvantage is that the auth service is in the critical path for every request. It must be highly available, horizontally scalable, and extremely fast. Any latency it adds is paid on every single API call.

Pattern 2: sidecar proxy

In a service mesh like Istio or Linkerd, a sidecar proxy runs alongside every service instance and intercepts all inbound and outbound traffic. The proxy handles mTLS between services and can be configured to validate JWT tokens before the request reaches the application container.

# Istio AuthorizationPolicy — validate JWT at the sidecar level
apiVersion: security.istio.io/v1beta1
kind: RequestAuthentication
metadata:
  name: jwt-validation
  namespace: production
spec:
  jwtRules:
  - issuer: "https://auth.example.com"
    jwksUri: "https://auth.example.com/.well-known/jwks.json"
    audiences:
    - "api.example.com"
    forwardOriginalToken: true
---
apiVersion: security.istio.io/v1beta1
kind: AuthorizationPolicy
metadata:
  name: require-jwt
  namespace: production
spec:
  action: DENY
  rules:
  - from:
    - source:
        notRequestPrincipals: ["*"]

The sidecar pattern offloads auth to infrastructure and keeps application code completely free of auth logic. The downside is operational complexity: you need a service mesh, which is a significant platform investment. You also lose flexibility — the sidecar validates the token but cannot easily do fine-grained authorization decisions that require application-level business logic.

Pattern 3: distributed token validation

Each service independently validates the JWT using a cached copy of the authorization server's public key. No network call to a centralized auth service is needed per request — the signature verification is a local cryptographic operation.

// Shared auth middleware, deployed in each service
import jwksClient from 'jwks-rsa';
import jwt from 'jsonwebtoken';

const client = jwksClient({
  jwksUri: 'https://auth.example.com/.well-known/jwks.json',
  cache: true,
  cacheMaxEntries: 5,
  cacheMaxAge: 10 * 60 * 1000  // cache keys for 10 minutes
});

async function getSigningKey(header): Promise<string> {
  return new Promise((resolve, reject) => {
    client.getSigningKey(header.kid, (err, key) => {
      if (err) return reject(err);
      resolve(key.getPublicKey());
    });
  });
}

export async function verifyToken(token: string): Promise<JwtPayload> {
  const decoded = jwt.decode(token, { complete: true });
  if (!decoded || typeof decoded === 'string') throw new Error('invalid token');

  const publicKey = await getSigningKey(decoded.header);

  return jwt.verify(token, publicKey, {
    algorithms: ['RS256'],
    audience: process.env.SERVICE_AUDIENCE,
    issuer: 'https://auth.example.com'
  }) as JwtPayload;
}
Always validate the aud (audience) claim in distributed validation. If service A's token can be replayed at service B because neither validates audience, an attacker who intercepts a token for a low-privilege service can replay it against a high-privilege one. Each service should only accept tokens explicitly issued for it.

The token fan-out problem

Microservices rarely operate in isolation. A single client request to an API gateway often triggers a cascade of service-to-service calls. Service A calls service B, which calls service C. Who is the authenticated user at each hop?

There are two models:

  • Token forwarding: the original user token is passed along every hop. Every service sees the same user identity. Simple, but the token must have appropriate scopes for every service in the chain, and you cannot distinguish service-to-service calls from user-to-service calls.
  • Token exchange: each service exchanges the incoming user token for a new token scoped to the next service using the OAuth 2.0 Token Exchange specification (RFC 8693). The new token asserts both the end user identity and the calling service identity.
// OAuth 2.0 Token Exchange (RFC 8693)
// Service A exchanges user token for a token to call Service B
async function exchangeTokenForService(
  userToken: string,
  targetAudience: string
): Promise<string> {
  const response = await fetch('https://auth.example.com/oauth/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
      subject_token: userToken,
      subject_token_type: 'urn:ietf:params:oauth:token-type:access_token',
      audience: targetAudience,
      scope: 'read:data',
      // Service A identifies itself with its own client credentials
      client_id: process.env.SERVICE_CLIENT_ID,
      client_secret: process.env.SERVICE_CLIENT_SECRET
    })
  });
  const data = await response.json();
  return data.access_token;
}

Token exchange adds a network call per hop but gives you a complete audit trail: each token in the chain carries both the original user identity and the acting service, letting you reconstruct exactly who initiated an action and which services handled it.

Choosing the right pattern

The centralized auth service pattern works well for most teams. It is easy to reason about, easy to test, and puts security logic in one place. The main risk is availability — mitigate it with aggressive caching at the gateway and circuit breakers.

The sidecar pattern makes sense if you are already invested in a service mesh and want to remove auth code from all your application services. The platform investment is high, but for large organizations running dozens of services, the long-term maintenance savings can justify it.

Distributed token validation is the highest-performance option and removes the centralized service availability dependency. It works well for large-scale deployments where the network overhead of calling a central auth service is measurable. The tradeoff is that revocation is harder — a revoked token remains valid until it expires, since there is no central check. Keep access token TTLs short (under 5 minutes) if you choose this pattern.

← Back to blog Try Bastionary free →