JWT and API protection
How a JWT is built, what its signature actually guarantees, how ASP.NET Core validates a token and where people get that validation wrong. At the end: how to carry a user’s identity through a chain of services and an API gateway without handing out tokens that open the whole system.
Before this topic: OAuth 2.0 and OpenID Connect.
What is a JWT made of and what does its signature guarantee?
Section titled “What is a JWT made of and what does its signature guarantee?”What it is
A JWT is a compact token that looks like xxxxx.yyyyy.zzzzz. The authorization server issues it, and the client sends it as a bearer token in the Authorization: Bearer ... header.
What it consists of
- Header — the signing algorithm and type:
{"alg":"RS256","typ":"JWT"}. - Payload — claims, that is statements about the user and the token itself:
subis who this is,expis when the token stops being valid. - Signature — a signature over the
header.payloadstring.
The header and payload are plain JSON in base64url. That is encoding, not encryption.
Common mistake
Assuming the token content is hidden. Anyone who sees the token can decode the base64url and read every claim. That is why passwords and personal data never go into the payload.
Takeaway: a JWT is signed but not encrypted JSON: it cannot be forged, but it can be read.
How it works
- The signature is computed over the string
base64url(header) + "." + base64url(payload)with the algorithm fromalg. Changing a single character in the payload breaks it. - Algorithms: HS256 is HMAC-SHA256 with a shared secret, RS256 and PS256 are RSA, ES256 is ECDSA on the P-256 curve. The
kidheader field says which of the issuer’s keys signed the token. - Registered claims (RFC 7519) are all optional. A profile makes them mandatory, for example RFC 9068 for access tokens.
| Claim | Meaning |
|---|---|
iss |
who issued the token |
sub |
who the token is about: a user or client id |
aud |
who it is for: which API must accept it |
exp, nbf |
valid until and not before; Unix time in seconds |
iat, jti |
when it was issued; a unique token id |
When to choose what
| JWS | JWE | |
|---|---|---|
| Parts in compact form | 3 | 5 |
| Protects | integrity and authenticity | confidentiality as well |
| Who can read the payload | anyone | the holder of the decryption key |
Pitfalls
- Signed does not mean valid. The signature says nothing about lifetime, issuer or audience — those are checked separately.
- HS256 is symmetric. Whoever can validate a token can also issue one.
Rule of thumb
Sign access tokens asymmetrically (RS256, ES256) and put identifiers and permissions in the payload, but no personal data.
Takeaway: JWS protects against forgery, not against reading; only JWE can hide the content.
Under the hood
- The JWS compact form (RFC 7515) is three base64url parts without
=padding, joined by dots. JWE (RFC 7516) has five: header, encrypted key, initialization vector, ciphertext and authentication tag. - A nested JWT is signed first and encrypted second: a JWE with a JWS inside. The payload is then both signed and hidden.
- The validation key comes from the validator’s configuration, not from the token.
kidonly selects a key from a set known in advance.
Cost
- Size. base64url adds a third to the JSON, and an RS256 signature with a 2048-bit key adds 256 bytes, or 342 characters once encoded. A token with a hundred roles easily weighs several kilobytes and rides along with every request.
- CPU. Signature validation does not touch the network, but it runs on every request in every service.
Trade-offs
| HS256 | RS256, ES256 | |
|---|---|---|
| Who validates | holders of the shared secret | anyone with the public key |
| Who can issue | any validator | only the private key holder |
| Where it fits | one service issues and validates its own tokens | an authorization server and many APIs |
Being self-contained is a trade-off too: validation needs no network, but you cannot revoke the token before exp without extra state.
How to check in production
- Decode the token locally (
JsonWebTokenHandler.ReadJsonWebToken) and look atalg,kid,iss,aud,exp. Do not paste production tokens into third-party online decoders. - Token size shows up in the
Authorizationheader at the proxy: 400 or 431 responses mean the header limit was exceeded.
Takeaway: a JWT is a signed, not a secret, statement by the issuer; an asymmetric signature separates whoever issues tokens from those who validate them.
using System.Security.Cryptography;using Microsoft.IdentityModel.JsonWebTokens;using Microsoft.IdentityModel.Tokens;
var key = new SymmetricSecurityKey(RandomNumberGenerator.GetBytes(32)); // 256 random bitsvar handler = new JsonWebTokenHandler();var parameters = new TokenValidationParameters{ ValidIssuer = "https://auth.example.com", ValidAudience = "orders-api", IssuerSigningKey = key,};
string token = handler.CreateToken(new SecurityTokenDescriptor{ Issuer = "https://auth.example.com", Audience = "orders-api", Claims = new Dictionary<string, object> { ["sub"] = "42" }, Expires = DateTime.UtcNow.AddMinutes(5), SigningCredentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256),});Console.WriteLine(token.Split('.').Length); // 3: header.payload.signature
// Readable without the key: the payload is only encodedvar jwt = handler.ReadJsonWebToken(token);Console.WriteLine($"{jwt.Alg} {jwt.Subject}"); // HS256 42
// Change sub in the payload — the signature no longer matchesvar parts = token.Split('.');var payload = Base64UrlEncoder.Decode(parts[1]).Replace("\"42\"", "\"1\"");var forged = $"{parts[0]}.{Base64UrlEncoder.Encode(payload)}.{parts[2]}";
Console.WriteLine((await handler.ValidateTokenAsync(token, parameters)).IsValid); // TrueConsole.WriteLine((await handler.ValidateTokenAsync(forged, parameters)).IsValid); // FalseHS256 is used here only to keep the example self-contained. In production the authorization server signs access tokens with an asymmetric key.
{ "alg": "RS256", "kid": "2026-10", "typ": "at+jwt" }
{ "iss": "https://auth.example.com", "sub": "42", "aud": "orders-api", "exp": 1791332100, "nbf": 1791331200, "iat": 1791331200, "jti": "5f0c6a9e-3b1d-4c55-9a7e-2d8f1b6e4c10", "scope": "orders.read"}The first object is the header, the second is the payload. The token was issued on 7 October 2026 at 00:00 UTC and is valid for 15 minutes; typ: at+jwt marks an access token per RFC 9068.
- How does base64url differ from base64? —
-and_instead of+and/, and no=padding, so the string fits into URLs and headers. - Can you extend a token by changing
exp? — No: changing the payload breaks the signature, and the token fails validation. - Which claims does RFC 7519 make mandatory? — None; profiles such as RFC 9068 for access tokens decide that.
- Why is
kidin the header? — To pick one of several keys the issuer publishes; you cannot rotate keys without it. - When do you need JWE? — When the payload must be hidden from the client and intermediaries; usually it is simpler to keep secrets out of it.
How does ASP.NET Core validate a JWT?
Section titled “How does ASP.NET Core validate a JWT?”What it is
JWT Bearer is an authentication scheme: the client sends a token in the Authorization: Bearer ... header, and the API validates it and learns who the user is. The authorization server issues the token; the API only validates it.
How to use
- Install the
Microsoft.AspNetCore.Authentication.JwtBearerpackage. - Call
AddAuthentication().AddJwtBearer(...)and setAuthorityandAudience. - Mark endpoints with
RequireAuthorization()or[Authorize]. - Inside a handler the user is available as
HttpContext.User, with claims taken from the token.
What gets checked
The signature, the issuer (iss), the audience (aud) and the lifetime (exp). If anything is wrong, the response is 401.
Common mistake
Turning off audience validation (ValidateAudience = false) “to make it work”. The API then accepts a token issued for any other API of the same authorization server.
Takeaway: AddJwtBearer with Authority and Audience, and the API validates tokens itself, without calling the authorization server on every request.
How it works
- On the first request the handler loads
{Authority}/.well-known/openid-configuration, then the JWKS from itsjwks_uri. That gives the expectedissand the signing keys. JsonWebTokenHandler(.NET 8+) selects the key bykidand verifies the signature.- It checks
iss,aud(the value ofAudience),expandnbfwith theClockSkewtolerance. - The claims become a
ClaimsPrincipalinHttpContext.User. On failure: 401 withWWW-Authenticate: Bearer error="invalid_token".
TokenValidationParameters settings
| Setting | Default |
|---|---|
ValidateIssuer, ValidateAudience, ValidateLifetime |
true |
RequireSignedTokens, RequireExpirationTime |
true |
ClockSkew |
5 minutes |
ValidAlgorithms, ValidTypes |
not set: anything goes |
Pitfalls
- Claims mapping.
MapInboundClaimsistrueby default:subbecomeshttp://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier. Turn it off and setNameClaimTypeandRoleClaimType. - Code from the .NET 7 days.
context.SecurityToken as JwtSecurityTokenin events returnsnullsince .NET 8: the token is aJsonWebToken.UseSecurityTokenValidators = truerestores the old behavior. RequireHttpsMetadata. It istrueby default; metadata over HTTP is acceptable only in development.
Rule of thumb
Set Authority and Audience, do not turn validations off, and for short-lived tokens reduce ClockSkew to 30–60 seconds.
Takeaway: the API verifies the signature with keys from the JWKS and the claims with TokenValidationParameters; the authorization server is needed only for metadata.
Under the hood
JwtBearerHandlerholds aConfigurationManager<OpenIdConnectConfiguration>. It re-reads metadata and the JWKS every 12 hours (AutomaticRefreshInterval).- If there is no key with that
kid,RefreshOnIssuerKeyNotFound(trueby default) requests an out-of-schedule refresh. It runs no more than once every 5 minutes (RefreshInterval). - Since .NET 8 the configuration manager is passed to
TokenValidationParameters.ConfigurationManager.JsonWebTokenHandlerkeeps a last known good configuration — the latest one that successfully validated tokens.
Cost
- Validation never touches the network: only parsing and the signature check. The first request after startup pays for loading metadata, and an authorization server that is down at that moment breaks authentication.
ClockSkewis added to everyexp: a 5-minute token effectively lives for 10.
Trade-offs
- Key rotation. Because of the cache, a new key must appear in the JWKS ahead of time, and the old one must stay until the last token it signed expires. Otherwise some requests get 401 between the rotation and the cache refresh.
- Token handlers.
JsonWebTokenHandleris about 30% faster and asynchronous.JwtSecurityTokenHandlerfromSystem.IdentityModel.Tokens.Jwtstays for compatibility. - Multiple issuers. One scheme means one
Authority. For two authorization servers you register two schemes and pick one withAddPolicyScheme.
How to check in production
JwtBearerHandlerlogs failures atInformationlevel, so lower the level for theMicrosoft.AspNetCore.Authenticationcategory. The code shows the reason:IDX10223— expired token,IDX10214— wrongaud,IDX10205— wrongiss.- The
OnAuthenticationFailedevent is the place for a failure metric by exception type.
Takeaway: JWT validation is a local operation against cached keys; in production its reliability depends on key rotation, ClockSkew and readable failure logs.
using System.Security.Claims;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication().AddJwtBearer(options =>{ // Discovery: {Authority}/.well-known/openid-configuration → issuer and jwks_uri options.Authority = "https://auth.example.com"; options.Audience = "orders-api"; // the expected aud options.MapInboundClaims = false; // sub stays "sub" instead of a long URI options.TokenValidationParameters.NameClaimType = "name"; options.TokenValidationParameters.RoleClaimType = "role"; options.TokenValidationParameters.ClockSkew = TimeSpan.FromSeconds(30); // instead of 5 minutes});builder.Services.AddAuthorization();
var app = builder.Build();
app.UseAuthentication();app.UseAuthorization();
// No token: 401; a valid token: the sub valueapp.MapGet("/me", (ClaimsPrincipal user) => user.FindFirst("sub")?.Value) .RequireAuthorization();
app.Run();- Does the API call the authorization server on every request? — No: metadata and keys are cached and refreshed every 12 hours or on an unknown
kid. - Why does an expired token keep working for a few minutes? —
ClockSkewdefaults to 5 minutes. - Why does
User.FindFirst("sub")returnnull? —MapInboundClaimsturnedsubintoClaimTypes.NameIdentifier. - What changed in JwtBearer in .NET 8? —
JsonWebTokenHandlerby default andJsonWebTokenin events;UseSecurityTokenValidators = truebrings the old behavior back. - How do you accept tokens from two authorization servers? — Two
AddJwtBearerschemes plusAddPolicySchemethat picks one, for example byiss.
What vulnerabilities and mistakes are common with JWT?
Section titled “What vulnerabilities and mistakes are common with JWT?”What it is
JWT mistakes almost always come down to two things: the API accepts a token it should not, or the token reveals too much.
Common mistakes
- Incomplete validation. With
ValidateAudienceorValidateLifetimeturned off, a token for another service or an expired token gets through. - Weak secret. An HS256 secret like
"secret123"is brute-forced from a single intercepted token, and the attacker can mint any token. - Secrets in the payload. Anyone who sees the token reads the payload: in logs, in the browser, at a proxy.
- Long lifetime. A one-day token stolen from a log works for a day: there is no built-in way to revoke it.
How to protect
Validations on, access tokens for 5–15 minutes, only identifiers in the payload.
Takeaway: validate everything you can, keep tokens short and keep secrets out of them.
How it works
The client brings the token, so both the header and the payload are under the attacker’s control. Attacks exploit an API that trusts them before validation.
| Mistake | Consequence | Defense |
|---|---|---|
Accepting alg: none |
an unsigned token passes | RequireSignedTokens (true by default), ValidAlgorithms |
Taking the algorithm from alg for any key |
RS256 → HS256 with the public key as the secret | algorithm bound to the key type, ValidAlgorithms |
| Short or dictionary HS256 secret | offline brute force and forged tokens | a random key of 256+ bits or an asymmetric signature |
Not validating aud |
a token for another API is accepted | Audience and ValidateAudience = true |
| Tokens living hours or days | a stolen token works for a long time | 5–15 minutes and a refresh token on the client |
| Personal data in the payload | leaks through logs and the browser | identifiers only |
| Hundreds of roles and groups in the token | header over the proxy limit, 400 or 431 | load permissions in the API instead of carrying them |
Pitfalls
- Revocation. Blocking a user or signing them out does not cancel an access token already issued. You need a mechanism on top: a list of revoked
jtivalues or introspection. ValidateIssuerSigningKey. The signature is verified without it. The flag adds an extra check of the key itself, such as certificate validity, and is off by default.
Rule of thumb
Algorithm, key, issuer, audience and type come from the API’s configuration, not from the token.
Takeaway: nearly every JWT attack is trusting token data before validation; an algorithm allowlist, full validation and short lifetimes close them.
Under the hood
- Algorithm confusion. A vulnerable library calls
verify(token, key)with the algorithm fromalg. If the API knows a public RSA key and the attacker sets HS256, the library computes an HMAC using the public key as the secret — and that key is published in the JWKS.Microsoft.IdentityModelchecks that the algorithm matches the key type, so an RSA key is never used for HS256. - Key from the token. The
jwk,jkuandx5uheaders carry a key or its URL inside the token. A validator that trusts them verifies the signature with the attacker’s key, and fetchingjkuorx5uis a ready-made SSRF.kidis attacker input as well: RFC 8725 warns about SQL and LDAP injection through it. - Token confusion. An id_token or a token for another API is signed by the same issuer and passes signature validation. The defense is
audplus an explicittyp, such asat+jwtfrom RFC 9068 viaValidTypes. - Weak key. Current
Microsoft.IdentityModelrejects HS256 keys shorter than 256 bits (IDX10720), but length does not guarantee entropy: a 40-character dictionary passphrase can still be cracked.
Trade-offs
| Approach | Pro | Con |
|---|---|---|
| Short JWT without revocation | no state, no network | a window until exp after theft |
JWT plus a revoked jti list |
fast revocation | shared storage, a lookup on every request |
| Reference token and introspection | instant revocation, hidden payload | a call to the authorization server; caching brings the window back |
How to check in production
- Negative tests in CI: tokens with
alg: none, a foreignaud, an expired one and one signed with someone else’s key all get 401. - The full
Authorizationheader must never end up in logs or traces.
Takeaway: a JWT is as safe as the API’s distrust of it; revocation is a deliberate choice between state and a vulnerability window.
using System.Collections.Concurrent;using Microsoft.AspNetCore.Authentication.JwtBearer;using Microsoft.IdentityModel.JsonWebTokens;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<RevokedTokens>();builder.Services.AddAuthentication().AddJwtBearer(options =>{ options.Authority = "https://auth.example.com"; options.Audience = "orders-api"; // aud validation stays on options.TokenValidationParameters.ValidAlgorithms = ["RS256"]; // neither none nor HS256 options.TokenValidationParameters.ValidTypes = ["at+jwt"]; // access tokens only
// Revocation before exp: a jti from the revoked list gets 401 options.Events = new JwtBearerEvents { OnTokenValidated = context => { var revoked = context.HttpContext.RequestServices.GetRequiredService<RevokedTokens>(); if (context.SecurityToken is not JsonWebToken jwt || revoked.ContainsKey(jwt.Id)) context.Fail("Token revoked"); return Task.CompletedTask; } };});
var app = builder.Build();app.MapGet("/orders", () => "ok").RequireAuthorization();app.Run();
// jti → exp; in production a shared store, for example Redis with a TTL until exppublic class RevokedTokens : ConcurrentDictionary<string, DateTimeOffset>;ValidTypes = ["at+jwt"] works only if your authorization server sets that typ; many set JWT.
- Why not carry every role and group in the token? — The size grows on every request, and permission changes are invisible until a new token is issued.
- How do you revoke a JWT before it expires? — A list of revoked
jtivalues kept until theirexp, or reference tokens with introspection. - Why can’t a password be used as an HS256 key? — Too little entropy: the key is cracked offline from one token; RFC 8725 explicitly forbids it.
- What makes
kiddangerous? — It is attacker input; only look it up in a known key set and never put it into SQL or LDAP queries. - Why
typ: at+jwt? — To tell an access token from an id_token and other JWTs of the same issuer.
How do you pass identity between services and through an API gateway?
Section titled “How do you pass identity between services and through an API gateway?”What it is
In microservices a user request passes through several services: gateway → orders → billing. Each one needs to know who is calling and on whose behalf.
How to use
- Token forwarding. A service puts the user’s token it received into the
Authorizationheader of its request to the next service. - Client credentials. A service gets its own token with a
client_idand a secret or certificate. Background jobs, where there is no user, work this way. - API gateway. A single entry point: it routes requests, validates the token and rate-limits. The token travels on to the services.
Common mistake
Validating the token only at the gateway and having services trust an X-User-Id header. Anyone who gets into the internal network can put someone else’s id there.
Takeaway: the user travels as a token, a service presents its own token, and every service validates what it receives.
How it works
| Option | Whose identity is in the token | Risk |
|---|---|---|
| Token forwarding | the user’s | broad aud: the token is valid in every service |
| Client credentials | the service’s | the user is lost, the service acts with its own rights |
| Token exchange | the user’s in sub, the service’s in act |
a call to the authorization server per hop |
Token exchange step by step
- orders-api receives the user’s token with
aud=orders-api. - It sends it to the token endpoint with
grant_type=urn:ietf:params:oauth:grant-type:token-exchange,subject_tokenandaudience=billing-api, authenticating with its ownclient_id. - The authorization server checks its policy and issues a token with
aud=billing-api, the samesuband, for delegation,actwith orders-api’s identity. - billing-api validates the token as usual and sees both the user and the calling service.
In Microsoft Entra ID the same scenario is the On-Behalf-Of flow; in .NET Microsoft.Identity.Web handles it.
Pitfalls
- Confused deputy. A service with client credentials runs a user’s request with its own broad rights. If it did not check the user’s permissions itself, the user gains access through the service.
- Broad
aud. Forwarding works only if the token is issued for all services at once — a key to the whole system.
Rule of thumb
There is a user — token exchange or On-Behalf-Of. No user — client credentials. Forward tokens only within a single trust boundary.
Takeaway: choosing an option means choosing whose identity and which permissions reach the last service in the chain.
Under the hood
- The gateway checks the signature,
iss,audand lifetime, drops garbage and applies rate limits. The service behind it validates the token again, locally against the cached JWKS, with no network call. - Phantom token. Outside, the client holds an opaque reference token; the gateway introspects it and passes a JWT inside. Claims do not leak out, and validation inside stays local.
- mTLS encrypts traffic and proves which service is calling. A service mesh (Istio, Linkerd) turns it on without code. RFC 8705 binds the token to the client certificate through the
cnfclaim. Where a client certificate is impractical, such as in a SPA, DPoP (RFC 9449) solves the same problem: the client signs each request with its key.
Cost
- Token exchange is a call to the authorization server per hop. Exchanged tokens are cached per “user — audience” pair until
exp, otherwise the authorization server becomes a bottleneck. - Short-lived tokens narrow the window after theft but mean more issuing and exchanging.
Trade-offs
- Validation only at the gateway is cheaper but turns the internal network into a trust zone: bypassing the gateway or compromising one service inside gives full access. Zero trust means validation in every service plus mTLS.
- YARP as a gateway. Authentication is a regular
AddJwtBearer, and routes get anAuthorizationPolicy. By default theAuthorizationheader goes to the service as is; replacing or exchanging the token is done in a request transform.
How to check in production
subandactin logs and traces show on whose behalf and by whom each step was done.- A test: a request to a service around the gateway, with no token or with a token for another
aud, gets 401.
Takeaway: the user reaches the end of the chain through token exchange with a narrow aud, and trust in the network is replaced by token validation in every service plus mTLS.
using System.Net.Http.Json;using System.Text.Json;
using var http = new HttpClient();string incomingToken = "eyJ..."; // from the incoming request's Authorization, aud=orders-api
// orders-api exchanges the user's token for a token for billing-apivar response = await http.PostAsync("https://auth.example.com/connect/token", new FormUrlEncodedContent(new Dictionary<string, string> { ["grant_type"] = "urn:ietf:params:oauth:grant-type:token-exchange", ["subject_token"] = incomingToken, ["subject_token_type"] = "urn:ietf:params:oauth:token-type:access_token", ["audience"] = "billing-api", // who the new token is for ["client_id"] = "orders-api", // who is exchanging: the service authenticates itself ["client_secret"] = "from-secret-store", }));
// The new token: aud=billing-api, the user's sub, act={"sub":"orders-api"}var json = await response.Content.ReadFromJsonAsync<JsonElement>();string billingToken = json.GetProperty("access_token").GetString()!;The authorization server has to support token exchange: Keycloak supports the standard version since 26.2, and in Duende IdentityServer you implement it as an extension grant.
// NuGet: Yarp.ReverseProxy, Microsoft.AspNetCore.Authentication.JwtBearerusing Yarp.ReverseProxy.Configuration;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication().AddJwtBearer(options =>{ options.Authority = "https://auth.example.com"; options.Audience = "orders-api";});builder.Services.AddAuthorization();builder.Services.AddReverseProxy().LoadFromMemory( [new RouteConfig { RouteId = "orders", ClusterId = "orders", AuthorizationPolicy = "default", // no valid token: 401 right at the gateway Match = new RouteMatch { Path = "/orders/{**rest}" }, }], [new ClusterConfig { ClusterId = "orders", Destinations = new Dictionary<string, DestinationConfig> { ["orders-1"] = new() { Address = "https://orders.internal/" }, }, }]);
var app = builder.Build();app.UseAuthentication();app.UseAuthorization();app.MapReverseProxy(); // Authorization goes to orders as is, and orders validates it againapp.Run();- Why not just forward the user’s token? — It needs an
audcovering every service; a token stolen from one works everywhere. - How does token exchange differ from On-Behalf-Of in Entra ID? — OBO is Microsoft’s flavor of the same scenario: the
jwt-bearergrant withrequested_token_use=on_behalf_of. - What is the
actclaim? — Per RFC 8693 it names who acts on behalf ofsub; a call chain becomes nestedactclaims. - Is validating the token at the gateway enough? — No, if the service is reachable around the gateway; zero trust requires validation in every service.
- What does mTLS add on top of JWT? — Service authentication at the transport level and, per RFC 8705, binding the token to a certificate.