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)
- Auth service is a top-3 caller in your dependency graph and its p95 latency correlates with overall API latency.
- Every request trace shows repeated
POST /introspectorGET /session/validatespans across hops in distributed tracing. - Outages at the identity server take down otherwise healthy services (correlated 401/503 spikes).
- Internal endpoints have
[AllowAnonymous]“because they are internal”, or trust anX-User-Idheader without verification. - Each service has its own user table or password check, or shares a session database.
- Downstream services log “user unknown” because identity is lost after the first hop, so audit records lack the real actor.
- 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 locallyLevel 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 tokenbuilder.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.
Database link
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 directoryvar 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
groupsclaims 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
localStorageexposes them to XSS. Prefer the BFF pattern (Day 20): the browser holds an HttpOnly, Secure, SameSite cookie and the BFF holds the tokens. MSAL withsessionStorageis an accepted trade-off for pure SPAs but needs a strict Content-Security-Policy. - Audience confusion / confused deputy. Rejecting tokens whose
audis 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’sITokenAcquisition) 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
- Skipping
audvalidation (ValidateAudience = false) “to make it work”. - Trusting
X-User-Id-style headers from the gateway without a network policy or signed token. - Putting sensitive data (national ID, medical details) in the payload: JWTs are signed, not encrypted; anyone can Base64-decode them.
- Long-lived (days) access tokens with no refresh strategy.
- Using an ID token (for the client) as an access token (for the API).
- 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
| Approach | How identity is verified | Strengths | Weaknesses | Fit for claims system |
|---|---|---|---|---|
| Signed JWT access token (this pattern) | Local signature check | No per-hop network call; standard; carries claims | Hard to revoke; size; leakage risk | Default for internal APIs |
| Opaque token + introspection (RFC 7662) | Call IdP to introspect | Instant revocation; small tokens; no data exposed | Network call per check (mitigate with cache); IdP load | Partner APIs, very sensitive operations |
| Server-side session + cookie | Session store lookup | Easy revocation; token never in browser | Shared session store; sticky/state; harder for service-to-service | Monolith, BFF-to-browser leg |
| Mutual TLS only | Client certificate | Strong service identity | No end-user identity | Service-to-service; combine with tokens |
| Gateway-issued internal token (token exchange) | Gateway swaps external token for internal one | Internal token can be compact and audience-specific | Extra component to secure; custom logic | Large 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
jtideny-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-jwtorvalidate-azure-ad-tokenpolicy, 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
- Register API: set Application ID URI
api://claims-api, define scopeclaims.read, and app roleClaims.Approver. SetaccessTokenAcceptedVersionto2in the manifest if you use v2.0 issuer URLs. - Register SPA: add redirect URI, grant the API permission, enable the auth code flow with PKCE (no implicit flow).
- 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>- 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.
- 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.
- Register an API and a client in a dev Entra tenant (or use a local IdP such as Keycloak in Docker).
- Get a token with
curl(client credentials or device code) and callGET /claims/1withAuthorization: Bearer <token>. - Decode it at jwt.ms and inspect claims.
- Change
Audienceinappsettingsand 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
- 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.
- 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.
- 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.
