Consistent auth across a dozen microservices
Replacing per-service authentication with one issued-token model — so that adding a service no longer meant reimplementing authorisation and hoping it matched.
- Role
- Software Engineer · Igen Technologies
- Stack
- Node.jsPythonOAuth2JWTMicroservices
- One token model across every service
- Authorisation decisions moved out of per-service code
- New services inherit the rules instead of restating them
The problem
The platform had grown service by service, and each had solved authentication locally. Some validated a session against the user store directly. Some trusted an internal header. One trusted the caller entirely, on the reasoning that it sat behind the gateway and nothing external could reach it.
The individual decisions were defensible when they were made. Collectively they meant nobody could answer a simple question — what can this caller do? — without reading a dozen codebases, and the answer differed depending on which door you came through.
The risk that concerned me most was not a dramatic breach. It was drift: every new service was another chance to get it subtly wrong, and there was no shared thing for it to be wrong against.
Approach
One issuer, many verifiers. A single service issues tokens after authenticating a user. Every other service verifies signatures and reads claims. Nothing else talks to the user store, so there is exactly one place where "who are you" is decided.
Claims describe the caller, not the permission. A token says which organisation the caller belongs to and what role they hold. It does not say may delete invoices — that is the invoice service's decision, and encoding it in the token means every permission change becomes a token change.
# Verification is a shared library, not a per-service reimplementation.
# One place to fix an algorithm confusion bug, rotate a key, or add a claim.
def verify(token: str) -> Claims:
payload = jwt.decode(
token,
key=public_key_for(kid_of(token)),
# Pinned explicitly. Accepting whatever the header asks for is how
# "alg: none" and HMAC-with-the-public-key attacks get in.
algorithms=["RS256"],
audience=SERVICE_AUDIENCE,
issuer=ISSUER,
)
return Claims.model_validate(payload)Deny by default. Routes are unauthorised unless they declare otherwise. The previous pattern — open unless a check was added — fails silently every time somebody forgets, and the failure is invisible until it isn't.
The part that was harder than the design
Migrating without a flag day. Services accepted both the old and new schemes during a transition window, with the old path logged as deprecated so we could watch usage fall rather than guess at it. The last few callers were always something nobody remembered — a cron job, an internal tool, a partner integration — and finding them by log rather than by breakage was the whole reason for the dual-accept period.
Outcome
Authorisation stopped being a thing each service invented. Adding a service meant installing the shared verifier and declaring which roles a route required, so the security posture of a new service was the same as the existing ones by default rather than by review.
The reason I'd do it the same way again is unglamorous: the failure mode it removes is not an attack, it's drift. A dozen slightly different implementations will diverge, and the divergence is what eventually leaves a door open.