Manikandan — Manikandan
Microservices

Day 38: Access Token

ManikandanManikandan
15 min read·Updated Sep 16, 2022

The Access Token pattern means the caller's identity is verified once, at the edge (API Gateway or identity provider), and the result is captured in a signed, short-lived token (usually a JWT).

Intro

The Access Token pattern means the caller’s identity is verified once, at the edge (API Gateway or identity provider), and the result is captured in a signed, short-lived token (usually a JWT). That token travels with every internal hop, so downstream services can verify the signature locally and read the claims without calling the auth server again. In our insurance claims system, a claims adjuster logs in once, and the Claims, Policy, Payments and Documents services all trust the same signed token instead of each re-checking a password or session.

Why we need this

  • Scale of auth checks. One user action in the claims portal (“Open claim CLM-1042”) fans out to 4–8 internal calls. If each hop calls the identity server, auth traffic becomes a multiple of user traffic and the identity server becomes the busiest and most fragile component.
  • Stateless services. Microservices scale horizontally and should not share a session store. A self-contained signed token carries identity, so any instance can serve the request.
  • Propagation of identity. Downstream services need to know who and what they may do (adjuster, region, claim limit) for authorization and audit, not just that “the gateway said OK”.
  • Standardisation. OAuth 2.0 and OpenID Connect give a standard vocabulary (iss, aud, sub, exp, scopes, roles) and mature libraries in .NET and Angular, so teams do not invent their own schemes.

What problem it solves

Problem: validating credentials on every internal hop overloads the auth server.

Without the pattern, a typical failure story looks like this: the Claims API receives a request, calls the Auth service to validate a session ID, then calls Policy API, which calls Auth again, which calls Payments, which calls Auth again. Under the Monday-morning peak of claim submissions, the Auth service saturates, every service slows down, and login failures cascade into an outage of the whole platform even though Claims, Policy and Payments are healthy. Worse, teams often “fix” this by disabling auth on internal calls (“it’s the internal network, it’s fine”), which lets any compromised service call any other service as any user.

With access tokens: the identity provider signs a token once; every service validates it locally using the provider’s public keys (cached), in microseconds, with no network call in the hot path.

When it is needed (and when it is NOT)

Needed when:

  • You have two or more services that need to know the caller’s identity.
  • Clients are SPAs (Angular) or mobile apps calling APIs directly or through a gateway.
  • You need per-user or per-tenant authorization and audit trails across services.
  • You integrate third parties or partners who call your APIs with delegated access (OAuth 2.0 scopes).

Not needed / overkill when:

  • A single monolith with server-rendered pages and cookie sessions: a secure, HttpOnly session cookie is simpler and easier to revoke.
  • Pure machine-to-machine calls with no user context and only one hop: a managed identity or client-credentials token is enough, and you do not need to forward a user token.
  • Highly sensitive operations needing instant revocation (for example, “freeze this adjuster right now”): a long-lived JWT is the wrong tool alone; you need short expiry plus revocation checks (see Level 3).

How to identify the problem (key signals)

  1. Auth service is a top-3 caller in your dependency graph and its p95 latency correlates with overall API latency.
  2. Every request trace shows repeated POST /introspect or GET /session/validate spans across hops in distributed tracing.
  3. Outages at the identity server take down otherwise healthy services (correlated 401/503 spikes).
  4. Internal endpoints have [AllowAnonymous] “because they are internal”, or trust an X-User-Id header without verification.
  5. Each service has its own user table or password check, or shares a session database.
  6. Downstream services log “user unknown” because identity is lost after the first hop, so audit records lack the real actor.
  7. Support tickets: “I was logged in but got kicked out randomly” caused by session-store failover or sticky-session problems.

Flow Diagram

Authenticate once at the edge; every hop validates the signed token locally.

sequenceDiagram
participant U as Angular + MSAL
participant E as Entra ID
participant G as APIM
participant C as Claims API
participant P as Policy API
U->>E: Auth code + PKCE
E-->>U: Access token (aud = claims-api)
U->>G: Bearer token
G->>G: validate-jwt
G->>C: Forward request
C->>C: Validate signature, iss, aud, exp, role
C->>E: On-behalf-of exchange
E-->>C: Token (aud = policy-api)
C->>P: Bearer token
P->>P: Validate locally

Level 1: Beginner

Analogy: A concert wristband. You show your ID and ticket once at the gate (identity provider). Staff give you a tamper-proof wristband with an expiry time (access token). Inside, every bar and stage only looks at the wristband; nobody goes back to the ticket office. If someone forges a wristband, the special pattern (signature) does not match.

A JWT has three Base64URL parts: header.payload.signature.

// Payload (claims) of an access token for an adjuster (illustrative)
{
"iss": "https://login.microsoftonline.com/<tenant-id>/v2.0",
"aud": "api://claims-api",
"sub": "b7c1e0c2-...-9f3a",
"name": "Priya Nair",
"roles": ["Claims.Adjuster"],
"scp": "claims.read claims.write",
"iat": 1790000000,
"exp": 1790003600
}

Minimal working example: validating a bearer token in an ASP.NET Core API (.NET 10 LTS).

// Program.cs (package: Microsoft.AspNetCore.Authentication.JwtBearer)
using Microsoft.AspNetCore.Authentication.JwtBearer;
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = "https://login.microsoftonline.com/<tenant-id>/v2.0";
options.Audience = "api://claims-api"; // token must be issued FOR this API
});
builder.Services.AddAuthorization();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapGet("/claims/{id}", (string id) => Results.Ok(new { id, status = "Open" }))
.RequireAuthorization();
app.Run();

Authority makes the middleware download the provider’s signing keys from its metadata endpoint and cache them; no call to the identity provider happens per request.

Level 2: Intermediate

Angular: get a token and attach it

Using MSAL Angular (@azure/msal-angular) with a functional setup and the built-in interceptor. The exact bootstrap API differs slightly between MSAL Angular majors; check the package README for your Angular version.

// app.config.ts (standalone, Angular 17+)
import { ApplicationConfig } from '@angular/core';
import { provideHttpClient, withInterceptorsFromDi, HTTP_INTERCEPTORS } from '@angular/common/http';
import { MsalInterceptor, MsalService, MSAL_INSTANCE, MSAL_INTERCEPTOR_CONFIG } from '@azure/msal-angular';
import { PublicClientApplication, InteractionType } from '@azure/msal-browser';
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(withInterceptorsFromDi()),
{ provide: HTTP_INTERCEPTORS, useClass: MsalInterceptor, multi: true },
{
provide: MSAL_INSTANCE,
useValue: new PublicClientApplication({
auth: {
clientId: '<spa-client-id>',
authority: 'https://login.microsoftonline.com/<tenant-id>',
redirectUri: '/'
},
cache: { cacheLocation: 'sessionStorage' }
})
},
{
provide: MSAL_INTERCEPTOR_CONFIG,
useValue: {
interactionType: InteractionType.Redirect,
protectedResourceMap: new Map([
['https://api.claims.example.com/', ['api://claims-api/claims.read']]
])
}
},
MsalService
]
};

The interceptor attaches Authorization: Bearer <token> only to URLs in protectedResourceMap, which prevents leaking tokens to third-party domains.

.NET: role/scope-based authorization and forwarding

builder.Services.AddAuthorization(o =>
{
o.AddPolicy("CanApproveClaims", p =>
p.RequireAuthenticatedUser()
.RequireRole("Claims.Approver"));
o.AddPolicy("ReadClaims", p =>
p.RequireAssertion(ctx =>
ctx.User.FindFirst("scp")?.Value.Split(' ').Contains("claims.read") == true));
});
app.MapPost("/claims/{id}/approve", (string id) => Results.Accepted())
.RequireAuthorization("CanApproveClaims");

Forwarding the user’s token to a downstream service (delegated call):

// Claims API -> Policy API, forwarding the caller's token
builder.Services.AddHttpContextAccessor();
builder.Services.AddHttpClient("policy", c => c.BaseAddress = new Uri("http://policy-api"))
.AddHttpMessageHandler<ForwardBearerHandler>();
public sealed class ForwardBearerHandler(IHttpContextAccessor accessor) : DelegatingHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken ct)
{
var auth = accessor.HttpContext?.Request.Headers.Authorization.ToString();
if (!string.IsNullOrEmpty(auth))
request.Headers.TryAddWithoutValidation("Authorization", auth);
return base.SendAsync(request, ct);
}
}

Important: the Policy API must still validate the token itself (signature, issuer, audience, expiry). Forwarding is not the same as trusting. If the token’s aud is api://claims-api, Policy API should reject it; use the on-behalf-of flow (Level 3) to get a token whose audience is Policy API.

Use the token’s sub/oid and a tenant or region claim to stamp audit columns and filter data (for example, in SQL Server or PostgreSQL, store CreatedBy from the claim, never from a request body).

var userId = user.FindFirst("oid")?.Value ?? user.FindFirst("sub")?.Value;
var region = user.FindFirst("region")?.Value; // custom claim mapped from the directory
var claims = await db.Claims.Where(c => c.Region == region).ToListAsync();

Level 3: Advanced

Validation checklist (all of these, every time)

Signature (algorithm pinned, keys from the issuer’s JWKS), iss, aud, exp/nbf (with small clock skew, default 5 minutes in .NET, often lowered to 30–60 s), and required scope/role. Never accept alg: none; never decode a JWT without verifying it.

Performance

  • Signature validation is local CPU work (RSA-256 verify is fast, well under a millisecond). The JWKS is cached and refreshed automatically by the middleware on key rotation.
  • Keep tokens small. Large groups claims can blow past header size limits (Entra ID switches to a “groups overage” indicator when a user is in too many groups); use app roles instead of raw group lists.

Scalability

No shared state is needed; any instance validates independently. The identity provider is hit only at sign-in and token refresh, not per request.

Security and failure modes

  • Lifetime vs revocation. A JWT is valid until it expires even if the user is disabled. Use short lifetimes (Entra ID default access token lifetime is randomised between 60 and 90 minutes; you can configure shorter lifetimes) and refresh tokens. For instant revocation on sensitive actions, use Continuous Access Evaluation (CAE) where supported, a deny-list keyed on jti/sub, or a re-check against the IdP for high-risk endpoints.
  • Token leakage in the browser. Storing tokens in localStorage exposes them to XSS. Prefer the BFF pattern (Day 20): the browser holds an HttpOnly, Secure, SameSite cookie and the BFF holds the tokens. MSAL with sessionStorage is an accepted trade-off for pure SPAs but needs a strict Content-Security-Policy.
  • Audience confusion / confused deputy. Rejecting tokens whose aud is not yours prevents a token issued for Service A being replayed against Service B.
  • Token forwarding chains. Blindly forwarding a broad token to every service means one compromised service can replay it anywhere. Prefer the OAuth 2.0 On-Behalf-Of flow (MSAL: AcquireTokenOnBehalfOf, or Microsoft.Identity.Web’s ITokenAcquisition) so each hop gets a token with the correct audience and minimal scope.
  • Key rotation outage. If you hard-code signing keys instead of using metadata discovery, rotation breaks production. Always use Authority/metadata.
  • Logging. Never log the full token. Log jti, sub, aud.

Common mistakes

  1. Skipping aud validation (ValidateAudience = false) “to make it work”.
  2. Trusting X-User-Id-style headers from the gateway without a network policy or signed token.
  3. Putting sensitive data (national ID, medical details) in the payload: JWTs are signed, not encrypted; anyone can Base64-decode them.
  4. Long-lived (days) access tokens with no refresh strategy.
  5. Using an ID token (for the client) as an access token (for the API).
  6. Using symmetric HS256 keys shared among many services; a compromise of any one service lets an attacker mint tokens. Prefer asymmetric signing (RS256/ES256) so services only hold the public key.

Level 4: Expert and Architect view

Alternatives compared

ApproachHow identity is verifiedStrengthsWeaknessesFit for claims system
Signed JWT access token (this pattern)Local signature checkNo per-hop network call; standard; carries claimsHard to revoke; size; leakage riskDefault for internal APIs
Opaque token + introspection (RFC 7662)Call IdP to introspectInstant revocation; small tokens; no data exposedNetwork call per check (mitigate with cache); IdP loadPartner APIs, very sensitive operations
Server-side session + cookieSession store lookupEasy revocation; token never in browserShared session store; sticky/state; harder for service-to-serviceMonolith, BFF-to-browser leg
Mutual TLS onlyClient certificateStrong service identityNo end-user identityService-to-service; combine with tokens
Gateway-issued internal token (token exchange)Gateway swaps external token for internal oneInternal token can be compact and audience-specificExtra component to secure; custom logicLarge estates with several IdPs

Combines with

API Gateway (Day 19) for edge validation, BFF (Day 20) to keep tokens out of the browser, Externalized Configuration (Day 39) for authority/audience/secret values, Audit Logging (Day 34) to record sub per action, Circuit Breaker/Retry (Days 26, 28) around token acquisition, Service Mesh (Day 48) for mTLS between pods.

ADR (architecture review style)

ADR-038: Use OAuth 2.0 access tokens (JWT) validated at the gateway and at each service

  • Status: Proposed
  • Context: The claims platform has 9 services and an Angular portal. Peak load causes the session-validation endpoint to be the bottleneck. Auditors require per-user traceability across services.
  • Decision: Microsoft Entra ID issues RS256-signed access tokens with a 60-minute lifetime. API Management validates the token at the edge (issuer, audience, expiry, scope). Each .NET service also validates the JWT locally with AddJwtBearer (defence in depth). Service-to-service calls that act for a user use the On-Behalf-Of flow; background jobs use managed identity/client credentials. The Angular app uses MSAL with authorization code + PKCE.
  • Consequences: (+) No per-hop identity calls; (+) uniform claims for audit; (−) revocation is bounded by token lifetime, mitigated by CAE and a jti deny-list on payout approval; (−) teams must handle refresh and audience correctly; (−) claim design (roles, region) becomes a governed contract.
  • Alternatives rejected: opaque tokens with introspection for all calls (extra latency and IdP dependency); shared session store (state coupling).

Azure implementation

Services

  • Microsoft Entra ID (identity provider): app registrations for the SPA (public client, auth code + PKCE) and for each API (expose an Application ID URI and scopes/app roles).
  • Azure API Management (APIM): edge validation with the validate-jwt or validate-azure-ad-token policy, rate limits and header transformation.
  • Azure App Service / Container Apps / AKS: host the .NET services. App Service and Container Apps offer built-in authentication (“Easy Auth”) as an option, but explicit AddJwtBearer/Microsoft.Identity.Web in code gives finer control.
  • Managed identities: for service-to-service calls without user context (for example, a nightly job calling Payments), avoiding secrets entirely.
  • Azure Key Vault: holds any client secrets or certificates (for On-Behalf-Of confidential clients); prefer certificates or federated credentials over secrets.
  • Application Insights / Azure Monitor: sign-in and token failure telemetry; Entra sign-in logs for the identity side.

Configuration highlights

  1. Register API: set Application ID URI api://claims-api, define scope claims.read, and app role Claims.Approver. Set accessTokenAcceptedVersion to 2 in the manifest if you use v2.0 issuer URLs.
  2. Register SPA: add redirect URI, grant the API permission, enable the auth code flow with PKCE (no implicit flow).
  3. APIM inbound policy (illustrative):
<inbound>
<validate-azure-ad-token tenant-id="<tenant-id>">
<client-application-ids>
<application-id><spa-client-id></application-id>
</client-application-ids>
<audiences>
<audience>api://claims-api</audience>
</audiences>
<required-claims>
<claim name="scp" match="any">
<value>claims.read</value>
</claim>
</required-claims>
</validate-azure-ad-token>
<base />
</inbound>
  1. In the .NET services, keep validating the token (do not rely solely on APIM). Restrict the services to accept traffic only from APIM/VNet via private endpoints or network rules so the gateway cannot be bypassed.
  2. Use Microsoft.Identity.Web (AddMicrosoftIdentityWebApi) if you want Entra-specific conveniences and On-Behalf-Of support.

Pricing and tier considerations

Verify current prices on the Azure pricing pages before budgeting; tiers and prices change.

  • Entra ID: Free tier covers basic sign-in and app registrations; P1/P2 add Conditional Access, Identity Protection and other governance features, and Conditional Access is what usually justifies P1 for an insurer. Entra External ID is the customer-facing option (priced per monthly active user with a free allowance).
  • API Management: Consumption (serverless, pay per call, no VNet integration in classic tier), Developer (non-production only, no SLA), Basic, Standard, Premium, and the newer v2 tiers (Basic v2, Standard v2, Premium v2). Choose based on VNet needs, SLA and scale; token validation policies are available across tiers.
  • Key Vault, Managed Identity: Managed identity is free; Key Vault is billed per operation and per certificate/HSM key.

Reference architecture (text)

Adjuster opens the Angular portal (Static Web Apps or App Service) → redirected to Entra ID (auth code + PKCE) → receives access token for api://claims-api → portal calls APIM → APIM validates token (issuer, audience, expiry, scope), applies rate limit, forwards to Claims API in Container Apps over a private endpoint → Claims API validates JWT again, checks role/policy, reads/writes Azure SQL or PostgreSQL stamping oid for audit → for policy lookup, Claims API uses On-Behalf-Of to get a Policy API token → Policy API validates it. Telemetry flows to Application Insights; Entra sign-in logs and APIM logs go to Log Analytics. Background reconciliation jobs use managed identity with app roles.

Teaching guide for my team

Beginner, 2 minutes

“Logging in once gives you a signed pass called an access token. It says who you are, who it is for (the audience), what you can do, and when it expires. Every service checks the signature on the pass using a public key; nobody calls the login server again. A tampered or expired pass is rejected with 401. A valid pass without permission gets 403.”

Intermediate, 5 minutes

Walk through the flow: Angular + MSAL gets a token via auth code + PKCE → gateway validates → API validates again (iss, aud, exp, signature) → authorization policies use roles/scp → downstream calls use On-Behalf-Of. Cover why signed is not encrypted, why we keep lifetimes short, why aud matters, and where revocation gaps are and how to close them (CAE, deny-list for high-risk actions).

Hands-on exercise

Build a tiny Claims API with AddJwtBearer, then test with three tokens: a valid one, one with the wrong aud, and an expired one.

  1. Register an API and a client in a dev Entra tenant (or use a local IdP such as Keycloak in Docker).
  2. Get a token with curl (client credentials or device code) and call GET /claims/1 with Authorization: Bearer <token>.
  3. Decode it at jwt.ms and inspect claims.
  4. Change Audience in appsettings and repeat.

Expected outcome: valid token → 200; wrong audience → 401 with invalid_token and error The audience ... is invalid; expired token → 401 with The token expired; valid token missing the Claims.Approver role on POST /claims/1/approve → 403.

Interview-style questions

  1. Why validate the token again in each service if the gateway already did? Defence in depth and zero trust: the network path may be bypassed, and each service needs the verified claims for its own authorization and audit.
  2. Is a JWT encrypted? No. It is Base64URL-encoded and signed, so anyone can read it; never put secrets or sensitive personal data in it.
  3. How do you revoke a JWT before it expires? You cannot cancel the token itself; use short lifetimes with refresh tokens, Continuous Access Evaluation, a deny-list on jti, or introspection for sensitive operations.

Mastery checklist

  • Can explain the difference between authentication, authorization, ID token and access token.
  • Can list what must be validated on a JWT (signature, iss, aud, exp/nbf, scope/role) and configure it in ASP.NET Core.
  • Can explain why metadata discovery (Authority) beats hard-coded keys, and what happens on key rotation.
  • Can set up Angular auth code + PKCE with MSAL and restrict where tokens are sent.
  • Can choose between forwarding a token, On-Behalf-Of, and client credentials/managed identity for each call type.
  • Can design a revocation strategy appropriate to risk.
  • Can explain where tokens should and should not be stored in the browser, and when to use a BFF.
  • Can write an ADR justifying token format, lifetime and validation points.

Key takeaway

Authenticate once at the edge, carry the result in a short-lived signed token, and have every service verify it locally with strict issuer, audience and expiry checks. Signed is not secret, and valid is not the same as permitted.

Interactive Architectural Roadmaps

Explore Complete Roadmaps & Pattern Checklists

Track your learning with interactive checklists for all 23 Gang of Four patterns and modern Microservice architecture patterns.

Share:
Back to Blog

Related Posts

View All Posts
Microservices

Day 41: Service Template

A Service Template is a ready-made skeleton repository that every new microservice starts from.

Manikandan
Manikandan·17 min read
Microservices

Day 40: Microservice Chassis

A Microservice Chassis is a shared, versioned starter library (in .NET: a NuGet package, or a small set of them) that every service references so that logging, tracing, metrics, health checks, authentication, error...

Manikandan
Manikandan·13 min read
Microservices

Day 39: Externalized Configuration

Externalized Configuration means the compiled artifact (a container image or a published build) contains no environment-specific settings and no secrets.

Manikandan
Manikandan·15 min read