Skip to content
By Authentication

Authentication and Authorization in Microservices: What Works

In a monolith you check who someone is once. In microservices, every hop has to ask again. Here is how I design authentication and authorization across services: edge auth, per-service verification, workload identity with SPIFFE, and centralized policy.

Authentication and Authorization in Microservices: What Works, by Deepak Gupta on guptadeepak.com

In a monolith, a user logs in once and the whole application knows who they are. One process, one session, one place to check permissions. Break that monolith into forty services and the model falls apart. A single click now fans out into a dozen internal calls, each one crossing a network boundary, and each one has to answer two questions: who is calling, and are they allowed to do this. I built and scaled LoginRadius to handle billions of logins, and the failures I saw most often came from teams treating microservice auth like monolith auth with more YAML. It is a different problem, and it rewards a different design. Here is the version I wish every team started with.

Authentication and authorization are two jobs, not one

Authentication answers "who are you." Authorization answers "what can you do." They sound similar, and teams keep collapsing them into one service and one check, which is the first mistake. In a distributed system they run in different places, at different times, using different data. Authentication happens once, at the front door, and produces a verifiable proof of identity. Authorization happens over and over, deep inside the system, every time a service touches a resource. If you build one component that tries to do both for every request, you get a bottleneck that every team has to route around, and eventually works around badly. Keep them separate and each one gets simpler. A concrete example: verifying a signed token to confirm a request comes from a logged in user is authentication, and it can happen anywhere the public key is available. Deciding that this particular user may refund that particular order is authorization, and it depends on business rules that change often. Wire the two together and every policy change forces you to touch your identity plumbing, which is how small edits turn into risky ones.

Authenticate once at the edge

The cleanest place to authenticate is the API gateway. The client logs in through an identity provider using OAuth 2.0 and OpenID Connect, gets a token, and presents it to the gateway on every request. The gateway verifies it before anything reaches an internal service. This gives you one well guarded entry point instead of asking every service to speak OIDC on its own.

If you have several kinds of client, a web app, a mobile app, a partner API, put a backend for frontend between each client and the services so it owns that client's session. It keeps token handling out of the browser, where tokens are hardest to protect, and gives you a natural place to apply per-client rules.

For the token itself, the pattern I recommend is the phantom token approach. The client holds an opaque token, a random string that means nothing on its own. The gateway calls the identity provider's introspection endpoint, exchanges that opaque token for a signed JWT, and forwards the JWT to internal services. The client never sees the JWT, so it cannot build logic on internal claims or leak them, and internal services get a self contained token they can verify without a round trip. The gateway caches the exchange until the token expires, so introspection stays off the hot path. Curity documents this pattern well, and it stays fully OAuth 2.0 compliant.

Validate tokens at every service, not just the gateway

A tempting shortcut is to let the gateway validate the token and have every service behind it trust whatever arrives. Do not do this. It assumes the network behind the gateway is safe, and that assumption is exactly what modern attackers exploit. Once someone is inside, a gateway-only model hands them the whole system. Trusting the edge alone also throws away defense in depth for a few milliseconds of saved work.

Zero trust says the opposite: every service verifies every call, no matter where it came from. In practice each service fetches the identity provider's public keys from its JWKS endpoint and checks four things on the JWT: the signature, the expiry, the issuer, and the audience. The audience check is the one people forget, and it matters more than the rest.

{
  "iss": "https://auth.internal",
  "sub": "user_8f2a",
  "aud": "orders-service",
  "scope": "orders:read orders:write",
  "exp": 1770000000
}

If the orders service accepts a token whose audience is the billing service, you have handed one service the right to spend another service's trust. Checking aud closes that door. The check is cheap and local, and it is what turns "we drew a zero trust diagram" into "we actually enforce it."

Give every service its own identity with mTLS and SPIFFE

User tokens tell you who the person is. They say nothing about which service is making the call. In a zero trust system that gap matters, because a service should not accept a request just because it arrived on the internal network. Each workload needs its own cryptographic identity.

The standard here is SPIFFE, with SPIRE as its runtime. SPIRE attests each workload and issues it a short lived identity document, called an SVID, in either X.509 or JWT form. These are typically valid for about an hour and rotate automatically before they expire, so a leaked credential is useful for minutes, not months. Services then establish mutual TLS, where both sides present and verify certificates, so every connection proves both who is calling and who is answering.

You rarely wire this by hand. A service mesh does it for you. Istio ships SPIFFE-compatible identity natively; its control plane issues X.509 SVIDs by default, and it can delegate to an external SPIRE deployment when you want one certificate authority across mesh and non-mesh workloads. Linkerd and Consul support the same model. Envoy sidecars pull certificates through SPIRE's Secret Discovery Service, so you get mutual TLS between services with no change to application code. This is the machine identity layer, and it is now the perimeter that actually matters.

Watch for the confused deputy

Here is the pitfall that quietly breaks naive token passing. Say the orders service receives a user's token and, to finish the job, calls the payments service. The easy move is to forward the same token straight through. Now the orders service can use that token to call anything the user is allowed to reach, not just payments. If orders is compromised or simply over-eager, it becomes a confused deputy: a trusted component tricked into using its access on an attacker's behalf. This is one of the oldest security bugs, and microservices reintroduce it constantly through token pass-through.

The fix is to stop reusing one token everywhere. Tokens should be scoped to a specific audience and a specific set of permissions, and when one service needs to call another it should mint a new token for exactly that call. OAuth 2.0 Token Exchange (RFC 8693) is the standard mechanism: the orders service presents the inbound token to the identity provider and gets back a fresh token whose audience is the payments service and whose scope is only what payments needs. A downstream service that receives a token addressed to someone else rejects it. Narrow tokens turn a single compromise into a contained one.

This matters more every year. As of 2026, a growing share of internal calls come from automated agents and workloads rather than humans, and they authenticate far more often than people do. Every one of them is a potential confused deputy if it carries a token broader than the task in front of it. Least privilege at the token level is no longer a nicety.

Centralize the authorization decision

Authorization logic has a way of metastasizing. It starts as one if-statement checking a role and ends as thousands of subtly different checks scattered across services, impossible to audit and impossible to change safely. The discipline that fixes this is separating the policy decision point from the policy enforcement point. Each service still enforces the answer, but it asks a central decision engine what the answer is.

Open Policy Agent (OPA) is the common choice. You write policy in one place, as data and rules, and services query it with the token claims plus request context. OPA returns allow or deny. A rule can be as simple as role based access control, or as rich as attribute based rules that weigh user attributes, resource sensitivity, and context like time or location.

allow {
  input.token.scope[_] == "orders:write"
  input.resource.owner == input.token.sub
}

The point is not OPA specifically. The point is that authorization rules live in one auditable place, get versioned like code, and change without a redeploy of every service that depends on them. When a regulator or a customer asks "who can access this," you have one answer, not forty.

What good looks like

Put together, a healthy microservices auth setup has a clear shape:

  • One front door. Clients authenticate at the gateway with OAuth 2.0 and OIDC. External tokens are opaque; internal tokens are short lived JWTs.
  • No implicit trust. Every service verifies signature, expiry, issuer, and audience on every request. The network is never assumed safe.
  • Workload identity. Services authenticate to each other with mutual TLS built on SPIFFE and SPIRE, usually through a service mesh, with short lived, auto-rotating certificates.
  • Scoped tokens. No blanket token pass-through. Services use token exchange to get narrow, audience-restricted tokens for downstream calls.
  • Externalized policy. Authorization decisions come from a central engine like OPA, enforced locally, versioned centrally.
  • Everything is logged. Every authentication and authorization decision produces an audit record you can actually query.

None of this is exotic. It is the same three ideas applied consistently: authenticate at the edge, verify identity at every hop, and decide permissions in one place. The teams that struggle are almost always missing one of the three, usually the middle one, and they find out during an incident. Get all three right and the rest of your security work gets easier, because you always know who is calling and what they are allowed to do.

Get the newsletter

New writing on identity, AI security, and building software, delivered when it ships. No tracking pixels, no funnels, unsubscribe with one click.