Mert Özen Further With Every Line
Security Authentication and Authorization — #08

JWT in Depth: JWS and JWE, Algorithm Choice, kid, JWKS and Signing Key Rotation

Mert Özen Nov 8, 2026 11 min 2 views
JWT in Depth: JWS and JWE, Algorithm Choice, kid, JWKS and Signing Key Rotation

Seamless signing key rotation takes three steps: publish the new key in the JWKS, wait until the APIs' caches refresh (12 hours by default), then sign with it and retire the old key once token lifetimes pass. We show JWS versus JWE, choosing ES256, alg none and algorithm confusion attempts, typ validation with at+jwt, kid and JWKS, the 8725bis draft's warnings and claim bloat with real outputs.

Version note: The code in this post was built and run with .NET 10 (ASP.NET Core 10.0.13 runtime), Microsoft.AspNetCore.Authentication.JwtBearer 10.0.14 and Microsoft.IdentityModel.JsonWebTokens 8.23.0.

Guess First

Bookstore's token service signs access tokens with ES256 and publishes the public keys at a JWKS (JSON Web Key Set) endpoint. It's time to change the signing key, and the team follows the order they know is right: first add the new key to the JWKS, then start signing tokens with it. The API (Application Programming Interface) side does nothing between these two steps. Does the first token signed with the new key pass the API?

token A -> 200
after publish+switch: B #1 -> 401, B #2 -> 200, B #3 -> 200

The first doesn't, the second does. The new key had been published, but the API's copy of the JWKS was stale; on seeing an unknown kid it scheduled a JWKS refresh and rejected that request. In an API with a hundred instances that means at least one failed request per instance at rotation time. In this post we open up the JWT (JSON Web Token): JWS (JSON Web Signature) and JWE (JSON Web Encryption), algorithm choice, typ, kid, JWKS and seamless key rotation. The basics of JWT validation (issuer, audience, lifetime, ValidAlgorithms, ClockSkew) were covered in post 13 of the Web Security (OWASP Top 10) series; here we build on top of that.

Anatomy of an Access Token

The lab's token service produces this token:

app.MapPost("/token", (TokenRequest request, SigningKeyRing ring) =>
{
    var handler = new JsonWebTokenHandler();
    string token = handler.CreateToken(new SecurityTokenDescriptor
    {
        TokenType = request.Type ?? "at+jwt",
        Issuer = issuer,
        Audience = request.Audience ?? "bookstore-api",
        IssuedAt = DateTime.UtcNow,
        NotBefore = DateTime.UtcNow,
        Expires = DateTime.UtcNow.AddMinutes(5),
        Claims = new Dictionary<string, object>
        {
            ["sub"] = request.Subject,
            ["client_id"] = "bookstore-web",
            ["scope"] = "books.read orders.read",
            ["jti"] = Guid.NewGuid().ToString("N")
        },
        SigningCredentials = new SigningCredentials(ring.Active, SecurityAlgorithms.EcdsaSha256)
    });
    return TypedResults.Ok(new { access_token = token, kid = ring.Active.KeyId });
});

Decoding the token's three parts:

length 458
{'alg': 'ES256', 'kid': 'bookstore-2026-11-a', 'typ': 'at+jwt'}
{'aud': 'bookstore-api', 'iss': 'http://localhost:19180', 'exp': 1794118708, 'iat': 1794118408, 'nbf': 1794118408, 'sub': 'ada', 'client_id': 'bookstore-web', 'scope': 'books.read orders.read', 'jti': '99b58766650a4d05931695567d427cf9'}
signature bytes 64

The choice of claims is not random. RFC (Request for Comments) 9068 defines a JWT profile for OAuth 2.0 access tokens: typ: at+jwt in the header, and iss, exp, aud, sub, client_id, iat and jti mandatory in the payload. This profile makes it harder to mix up an ID token and an access token; we'll see shortly why that matters. In the lab the issuer is http://localhost:19180 because the API reads the metadata from the same address; in a real setup it would be an HTTPS (HTTP Secure) address such as https://auth.bookstore.test.

The token is produced and validated by JsonWebTokenHandler. Since .NET 8, AddJwtBearer uses this handler by default; it is faster and more memory-friendly than the old JwtSecurityTokenHandler. Code tied to the old handler (custom validators added to the SecurityTokenValidators list, JwtSecurityToken casts) can be silently disabled when moving to .NET 8; the details are in post 18.

JWS or JWE?

The token above is a JWS: signed but not encrypted. Anyone can read the payload. For most access tokens that is enough, because there is no secret in it and the token travels over TLS (Transport Layer Security) anyway. If the data the token carries must be hidden from the client or a system in between, JWE is used. JsonWebTokenHandler can produce a nested JWT: sign first, then encrypt with the API's public key:

var descriptor = new SecurityTokenDescriptor
{
    TokenType = "at+jwt",
    Issuer = "https://auth.bookstore.test",
    Audience = "bookstore-api",
    Expires = DateTime.UtcNow.AddMinutes(5),
    Claims = new Dictionary<string, object> { ["sub"] = "ada", ["email"] = "ada@bookstore.test" },
    SigningCredentials = new SigningCredentials(signingKey, SecurityAlgorithms.EcdsaSha256),
    // Nested JWT: signed first (JWS), then encrypted for the API's public key (JWE)
    EncryptingCredentials = new EncryptingCredentials(encryptionKey, SecurityAlgorithms.RsaOAEP, SecurityAlgorithms.Aes256CbcHmacSha512)
};
string jwe = handler.CreateToken(descriptor);
JWE parts: 5, length: 1053
JWE header: {"alg":"RSA-OAEP","enc":"A256CBC-HS512","kid":"bookstore-enc-2026-11","typ":"at+jwt","cty":"JWT"}
valid: True, email: ada@bookstore.test

Five parts: the header, the encrypted content key, the IV (Initialization Vector), the ciphertext and the authentication tag. alg says how the content key is encrypted (RSA-OAEP: Optimal Asymmetric Encryption Padding), enc how the content is encrypted. The price is size and work: the same claims went from 458 characters to 1053, and the validating side needs the private key. Telling JWS and JWE apart is the validator's job; the JWT BCP (Best Current Practice) update being drafted, draft-ietf-oauth-rfc8725bis (revision -10 dated 21 August 2026, not yet an RFC), treats this as a separate threat under "encryption-signature confusion": in a nested JWT both the outer encryption and the inner signature must be validated.

Algorithm Choice: HS256, RS256, ES256

HS256 (HMAC-SHA256: Hash-based Message Authentication Code + Secure Hash Algorithm 256 bit) is symmetric: the signer and the validator know the same secret. That's fine if a single application both produces and validates the token. But if five services in Bookstore validate the token, all five know the secret, and if any one of them is compromised the attacker can produce any token they like. In every distributed system where a validator must not be able to sign, use an asymmetric algorithm: RS256 (an RSA signature in Public-Key Cryptography Standards (PKCS) #1 v1.5 format, with Secure Hash Algorithm 256 (SHA-256)) or ES256 (ECDSA (Elliptic Curve Digital Signature Algorithm) on the P-256 curve). An ES256 signature is 64 bytes, a 2048-bit RSA signature 256 bytes; key generation is also much faster. Bookstore uses ES256.

The server chooses the algorithm, not the token. The lab API accepts only ES256 and checks typ too:

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.Authority = issuer;
        options.RequireHttpsMetadata = false; // lab only: the issuer runs on http://localhost
        options.Audience = "bookstore-api";
        options.MapInboundClaims = false;
        options.RefreshInterval = TimeSpan.FromSeconds(builder.Configuration.GetValue("Lab:RefreshSeconds", 300));
        options.TokenValidationParameters.ValidTypes = ["at+jwt"];
        options.TokenValidationParameters.ValidAlgorithms = [SecurityAlgorithms.EcdsaSha256];
        options.TokenValidationParameters.ClockSkew = TimeSpan.FromSeconds(30);
    });

I made three classic attempts against my own lab: an unsigned token (alg: none), a token signed with HS256 using the public JWKS as the HMAC secret, and a token whose payload was changed to sub: grace:

== alg none
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token", error_description="The signature is invalid"
IDX10504: Unable to validate signature, token does not have a signature: ...
== HS256 signed with the public JWK as secret
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token", error_description="The signature is invalid"
IDX10511: Signature validation failed. Keys tried: 'Microsoft.IdentityModel.Tokens.ECDsaSecurityKey, KeyId: 'bookstore-2026-11-a', ...
== payload edited (sub=grace)
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token", error_description="The signature is invalid"
IDX10511: Signature validation failed. Keys tried: 'Microsoft.IdentityModel.Tokens.ECDsaSecurityKey, KeyId: 'bookstore-2026-11-a', ...

All three were rejected. The second one is the "algorithm confusion" attack that historically caused serious holes in systems using RS256: if the validator trusts the alg in the token and uses the public key as an HMAC secret, anyone who knows the public key can produce valid tokens. The defence has two layers: an allowlist with ValidAlgorithms, and using each key with a single algorithm. The 8725bis draft also asks for an allowlist of algorithms rather than a blocklist.

typ and Cross-JWT Confusion

The same issuer produces several kinds of JWT: access tokens, ID tokens, maybe e-mail verification tokens. If they are all signed with the same key, can one be used in place of another? ValidTypes = ["at+jwt"] prevents that. I had the token service produce a token with typ: JWT and sent it to the API:

== typ JWT
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token"
IDX10257: Token type validation failed. Type: 'JWT'. Did not match: validationParameters.TokenTypes: '[PII of type 'System.String' is hidden. ...]'.

The 8725bis draft is clear here: an explicit typ value is recommended for new JWT uses, and the generic JWT value does not count as explicit typing. Validation rules for different JWT kinds from the same issuer must be mutually exclusive. Audience does the same job on another axis: a token issued for Bookstore's admin API must not pass the orders API.

== wrong audience
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token", error_description="The audience '(null)' is invalid"
IDX10214: Audience validation failed. See https://aka.ms/identitymodel/app-context-switches

The '(null)' in the response is misleading: the token does have an audience, but the library leaves the value out of the message because of PII (Personally Identifiable Information) hiding. Don't trust error_description when debugging; look at the log.

kid and JWKS

A JWKS is the JSON (JavaScript Object Notation) document in which an issuer publishes its public keys. Each key has a kid (key ID), and the kid in the token header tells the validator which key to use. The lab's JWKS:

{"keys":[{"kty":"EC","use":"sig","alg":"ES256","kid":"bookstore-2026-11-a","crv":"P-256","x":"xZJQNOor3tjv2aOM_h3NATbzJRiVLXZba2gY1b0W_tE","y":"zX2MZv-npSZz-yWXyu-0kPu0zu4IfhKb5IDsLIrj_HA"}]}
app.MapGet("/.well-known/jwks.json", (SigningKeyRing ring) => TypedResults.Ok(new
{
    keys = ring.Published.Select(key =>
    {
        JsonWebKey jwk = JsonWebKeyConverter.ConvertFromECDsaSecurityKey(key);
        return new { kty = jwk.Kty, use = "sig", alg = "ES256", kid = jwk.Kid, crv = jwk.Crv, x = jwk.X, y = jwk.Y };
    })
}));

From the Authority setting the API finds /.well-known/openid-configuration, from there the jwks_uri, and caches the keys inside a ConfigurationManager. Private keys (the d parameter) never go into the JWKS; if ConvertFromECDsaSecurityKey is called on a key that holds the private key it fills in d as well, which is why I build the response field by field. From the validator's point of view kid is untrusted input: 8725bis asks that kid be sanitized before it is used in a file path or a SQL (Structured Query Language) query, and that headers carrying a URL (Uniform Resource Locator), such as jku and x5u, not be trusted without an allowlist.

Seamless Key Rotation

The default cache durations are the basis of the rotation plan:

DefaultAutomaticRefreshInterval=12:00:00
DefaultRefreshInterval=00:05:00
ClockSkew default=00:05:00

AutomaticRefreshInterval: the API refreshes the metadata and the JWKS at least every 12 hours. RefreshInterval: on seeing an unknown kid it can request a forced refresh, but it waits at least 5 minutes between two refreshes. In the opening experiment I had lowered this to 10 seconds. The refresh request doesn't rescue the current request; the new JWKS is used from the next request on. That's why the first B token got 401 and the second 200.

If the order is wrong, that is, if signing switches before the new key is published, things are much worse:

token A -> 200
switch-without-publish: {"active":"bookstore-2026-11-b","published":["bookstore-2026-11-a"]}
B #1 -> 401, B #2 -> 401
B after 11 s -> 401

Microsoft.IdentityModel.Tokens.SecurityTokenSignatureKeyNotFoundException: IDX10503: Signature validation failed. The token's kid is: 'bookstore-2026-11-b', but did not match any keys in TokenValidationParameters or Configuration. ...

The API refreshes the JWKS, but B isn't there; every new token is rejected. Seamless rotation takes three steps:

// Rotation in three steps: publish the new key, sign with it, retire the old one
public void Apply(string step)
{
    switch (step)
    {
        case "publish-next": Published = [_a, _b]; break;
        case "switch": Active = _b; break;
        case "retire-old": Published = [_b]; break;
        case "switch-without-publish": Active = _b; break;
        default: throw new ArgumentOutOfRangeException(nameof(step));
    }
}

Wait at least one AutomaticRefreshInterval (12 hours with the default) between the first and second steps; that way every validator has the new key cached in advance and not even the single failed request from the opening happens. Between the second and third steps wait at least the access token lifetime (5 minutes in Bookstore) plus ClockSkew; tokens signed with the old key must remain verifiable until they expire. In the lab, the A token still got 200 right after I removed the old key from the JWKS, because A was in the API's cache: removing has a delayed effect just like publishing. If the key was compromised, that delay is a problem; then you need to restart the APIs or refresh the metadata by hand.

Token Size and Claim Bloat

JWS with 0 permission claims: 350 chars
JWS with 40 permission claims: 2109 chars

Putting every permission into the token as a claim is tempting, but the token travels in the Authorization header on every request. 40 permission claims made the token six times larger; an admin account with a few hundred permissions hits the header limits of proxies. Also, the permissions in a token are frozen for its lifetime: even if you revoke a permission, the token stays valid until it expires. Putting a role or a coarse scope into the token and resolving fine-grained permissions on the API side is usually better; the details of that design are in post 16.

In Production

Don't return detailed error messages to the client. JwtBearer's IncludeErrorDetails setting is on by default and fills in error_description; consider turning it off in production and diagnose from logs. In the logs, PII is hidden by default; turn on IdentityModelEventSource.ShowPII only in development.

Measure: if the authenticated request duration in the Microsoft.AspNetCore.Authentication metrics jumps after JWKS refreshes, the metadata endpoint is slow. Make the token service's JWKS endpoint cacheable and run it with high availability; if that endpoint is down at the moment of switching to a new key, the APIs cannot validate any token.

Don't keep the private key in code or in the repository. The PEM (Privacy-Enhanced Mail) files in the lab were generated with openssl in the work folder; in production an HSM (Hardware Security Module) or a cloud key vault signs without ever handing out the key. The details of secret management are in the upcoming Configuration and Secret Management series.

Common Pitfalls

First, putting a JWT in place of a session and being unable to revoke it. A signed token is valid until it expires; logout, a password change or revoking a permission doesn't stop it. The fix is a short-lived access token plus a revocable refresh token kept on the server; the topic of the next post.

Second, starting to sign with the new key the moment you publish it. Third, removing the old key from the JWKS too early. Fourth, leaving ValidTypes and ValidAlgorithms empty; the library's defaults protect you in most cases, but 8725bis's warnings are exactly about the corners where defaults aren't enough.

Fifth, using JWE not for confidentiality but to be "more secure". Encryption hides the token from the client but solves none of the revocation, lifetime or audience problems; it adds size and key management on top.

A Small Experiment

★☆☆ Run the lab with --Lab:RefreshSeconds=300 and repeat the publish + switch experiment from the opening. How many requests fail and why? Then add an API restart between publish and switch and compare the result.

★★☆ Add a third key to the token service that signs with RS256 and publish it in the JWKS. Show that a token signed with this key is rejected without changing the API's ValidAlgorithms; then update it to accept both algorithms and confirm each key maps to only one algorithm.

★★★ Make SigningKeyRing time-based: give each key publishAt, activateAt and retireAt values and apply the steps by time instead of by hand. Show in a test that the three steps happen in the right order by advancing a fake clock with TimeProvider.

The JWT's biggest weakness is that it can't be revoked. In the next post we build the mechanism that balances it: refresh tokens, rotation, reuse detection that catches a stolen token being used again, and secure storage on the server side.