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

Protecting the API: Access Token Validation, Audience, Scope Policies, Client Credentials and DPoP

Mert Özen Nov 8, 2026 13 min 1 views
Protecting the API: Access Token Validation, Audience, Scope Policies, Client Credentials and DPoP

An API must accept not every validly signed token, but only one issued for its own audience and carrying the required scope. We wire AddJwtBearer to Keycloak, map scopes to policies, use introspection to show why a signed-out user's JWT still passes, write a DelegatingHandler that caches client credentials tokens, and verify in five scenarios that a stolen DPoP-bound token is useless.

Version note: The code in this post was built and run with .NET 10, Microsoft.AspNetCore.Authentication.JwtBearer 10.0.14, Microsoft.IdentityModel.JsonWebTokens 8.23.0 and Keycloak 26.8.2.

Guess First

The Bookstore API (Application Programming Interface) has AddJwtBearer in front and validates every token Keycloak signs. Ada signed out, and Keycloak closed the session. Does Ada's five-minute access token still work against the API? Second question: the inventory service sends 40 requests a minute; how many times should it ask for a token? Third: what can someone who copies an access token from a proxy log do with it?

In the previous two posts we were on the client side: getting tokens, validating the ID token, ending the session. In this post we move to the resource server, the API that accepts the token. The basics of JWT (JSON Web Token) validation (issuer, audience, lifetime, signature, algorithm) were covered in the "Authentication Failures (2)" post of the Web Security (OWASP Top 10) series and in the "JWT Authentication" post of the ASP.NET Core Web API series; there the app issued its own tokens. Here an authorization server issues them and the API only consumes them. In the lab the API is at http://localhost:19213, Keycloak at its usual place.

AddJwtBearer and Authority

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.Authority = builder.Configuration["Auth:Authority"]; // discovery + JWKS from here
        options.Audience = "bookstore-api";
        options.RequireHttpsMetadata = false; // local lab only
        options.MapInboundClaims = false;
        options.TokenValidationParameters.ValidAlgorithms = [SecurityAlgorithms.RsaSha256];
        options.TokenValidationParameters.NameClaimType = "preferred_username";
        // Events: in the DPoP section
    });

With Authority set, the handler takes the issuer and signing keys from the discovery document; we don't hand it an IssuerSigningKey as in the OWASP series. When Keycloak rotates its key, the handler sees an unknown kid and refetches the configuration. Audience is one line, but the most important one. With ValidAlgorithms we accept only the algorithm we use out of the thirteen Keycloak advertises in discovery. The reason for MapInboundClaims = false was in the previous post: the claim name in the token is the claim name in code.

A request without a token, and one with a token that has no audience:

no token:
401  WWW-Authenticate: Bearer
no scope token:
401  WWW-Authenticate: Bearer error="invalid_token", error_description="The audience 'empty' is invalid"

The second token is one the inventory service got via client credentials without asking for any scope. Its signature is valid, the issuer is right, it has not expired; but there is no aud in it and the API rejects it. The description in the WWW-Authenticate header is very helpful during development; if you don't want error details leaving production, set IncludeErrorDetails = false.

Audience: Each API Has Its Own Door

What would happen without audience validation? Imagine Bookstore also has a reporting API, and Keycloak issues tokens for it in the same realm. A token obtained for the reporting API would pass the Bookstore API, since it is signed with the same key. Worse, the reporting API could forward that token in its own call to the Bookstore API; tokens become "portable" from one API to the next. That is why RFC (Request for Comments) 9700 asks that tokens be restricted to specific resource servers and that a resource server reject tokens with the wrong audience.

In the lab we set the audience with Keycloak's audience mapper: each of the books.read, orders.read and orders.write client scopes adds aud: bookstore-api to the token. If the client requests none of these scopes, the Bookstore API's audience is not in the token at all. When Ada signs in with only the openid scope and calls the API:

ada openid only -> books:
401  WWW-Authenticate: Bearer error="invalid_token", error_description="The audience '(null)' is invalid"

RFC 8707 (Resource Indicators for OAuth 2.0) solves the same problem at the protocol level: when requesting a token, the client says which API it is for with a resource=https://api.bookstore.example parameter, and the authorization server sets aud accordingly. On an authorization server that supports it, getting separate, narrowly scoped tokens for several APIs is in the client's hands. Whichever way, the rule is the same: one token, one API.

From Scope to Policy

Keycloak sends scopes in a single scope claim, space-separated. ASP.NET Core's RequireClaim("scope", "books.read") compares the whole claim value, so it does not work for a value like "openid profile books.read". A small extension method is enough:

builder.Services.AddAuthorizationBuilder()
    .AddPolicy("books.read", policy => policy.RequireScope("books.read"))
    .AddPolicy("orders.read", policy => policy.RequireScope("orders.read").RequireEndUser());

app.MapGet("/books", () => new[] { "Dune", "Solaris", "Neuromancer" })
    .RequireAuthorization("books.read");

app.MapGet("/orders/mine", (ClaimsPrincipal user) => new
{
    Customer = user.Identity!.Name,
    Client = user.FindFirstValue("azp")
}).RequireAuthorization("orders.read");

static class ScopePolicies
{
    // Keycloak sends scopes in one claim, separated by spaces
    public static AuthorizationPolicyBuilder RequireScope(this AuthorizationPolicyBuilder policy, string scope) =>
        policy.RequireAuthenticatedUser().RequireAssertion(context =>
            context.User.FindFirstValue("scope")?.Split(' ').Contains(scope) == true);

    // Keycloak service account tokens carry a client_id claim; end-user tokens don't
    public static AuthorizationPolicyBuilder RequireEndUser(this AuthorizationPolicyBuilder policy) =>
        policy.RequireAssertion(context => !context.User.HasClaim(c => c.Type == "client_id"));
}

Some authorization servers send scopes as a JSON (JavaScript Object Notation) array or in a claim named scp (Microsoft Entra ID uses scp); write the extension method for your server and pin it down with a test. The internal mechanics of policies (requirements, handlers) are the subject of post 01 of the upcoming "ASP.NET Core Security" series; here we only look at the mapping.

books.read:
200 ["Dune","Solaris","Neuromancer"]
books.read -> orders:
403
worker orders.read -> orders:
403
ada orders.read -> orders:
200 {"customer":"ada@bookstore.test","client":"bookstore-spa"}

The third line matters. The inventory service's token has the orders.read scope, but /orders/mine means "my orders", and a machine has no "my". Without RequireEndUser the endpoint would run with the service account's sub. The fourth line shows what a scope is not: orders.read does not give Ada the right to read Grace's orders; the endpoint must return only the orders of the user in the token. A scope is the boundary of the client's delegation; the user's permissions are a separate layer, the subject of post 16.

Opaque Tokens and Introspection

The advantage of a JWT is that the API can validate it on its own, without asking the authorization server. The disadvantage is the same sentence. The first question from Guess First:

--- introspection
{
  "active": true,
  "client_id": "bookstore-spa",
  "username": "ada@bookstore.test",
  "scope": "openid profile orders.read email",
  "exp": 1794117144
}
logout (revoke session): 204
{"active":false}
API with same JWT after logout:
200 {"customer":"ada@bookstore.test","client":"bookstore-spa"}

Ada signed out, and Keycloak's introspection endpoint (RFC 7662, OAuth 2.0 Token Introspection) says the token is no longer active; yet the API keeps accepting the same token, because the signature is still valid and exp has not arrived. A JWT cannot be revoked; it can only expire. That is why the access token lifetime is short (300 seconds in the lab) and the long-lived thing is the refresh token, which we made revocable in post 09.

If you need real-time revocation there are two paths. The first is opaque tokens with introspection on every request: a network call to the authorization server, a dependency on it, and latency; caching the result briefly lowers the cost but brings back the revocation delay. The second is to keep JWTs and call introspection as an extra check on sensitive operations (payment, address change). The introspection call must be authenticated too; in the lab the API uses Basic authentication with its own client, bookstore-api.

Machine to Machine: A DelegatingHandler That Caches the Token

The inventory service calls the API with IHttpClientFactory. Fetching a token on every request is, as we saw in post 10, needless load on the authorization server. A DelegatingHandler that caches the token:

// Singleton: caches the token, renews it 30 seconds before expiry; concurrent requests share one token request
public sealed class ClientCredentialsTokenProvider(
    IHttpClientFactory factory, ClientCredentialsOptions options, TimeProvider clock)
{
    private static readonly TimeSpan RefreshBefore = TimeSpan.FromSeconds(30);
    private readonly SemaphoreSlim _lock = new(1, 1);
    private (string Token, DateTimeOffset ExpiresAt)? _cached;

    public int TokenRequests { get; private set; }

    public void Invalidate() => _cached = null;

    public async Task<string> GetTokenAsync(CancellationToken ct)
    {
        if (_cached is { } hit && hit.ExpiresAt - RefreshBefore > clock.GetUtcNow())
        {
            return hit.Token;
        }

        await _lock.WaitAsync(ct);
        try
        {
            if (_cached is { } again && again.ExpiresAt - RefreshBefore > clock.GetUtcNow())
            {
                return again.Token;
            }

            using HttpResponseMessage response = await factory.CreateClient("token").PostAsync(options.TokenEndpoint,
                new FormUrlEncodedContent(
                [
                    new("grant_type", "client_credentials"),
                    new("client_id", options.ClientId),
                    new("client_secret", options.ClientSecret),
                    new("scope", options.Scope)
                ]), ct);
            response.EnsureSuccessStatusCode();
            TokenResponse token = (await response.Content.ReadFromJsonAsync<TokenResponse>(ct))!;
            TokenRequests++;
            _cached = (token.AccessToken, clock.GetUtcNow().AddSeconds(token.ExpiresIn));
            return token.AccessToken;
        }
        finally
        {
            _lock.Release();
        }
    }

    private sealed record TokenResponse(
        [property: JsonPropertyName("access_token")] string AccessToken,
        [property: JsonPropertyName("expires_in")] int ExpiresIn);
}

// The handler itself is stateless: IHttpClientFactory recreates it when the handler lifetime ends
public sealed class ClientCredentialsHandler(ClientCredentialsTokenProvider tokens) : DelegatingHandler
{
    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
    {
        request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", await tokens.GetTokenAsync(ct));
        HttpResponseMessage response = await base.SendAsync(request, ct);
        if (response.StatusCode == HttpStatusCode.Unauthorized)
        {
            tokens.Invalidate(); // key rotation or revocation: the next request gets a new token
        }
        return response;
    }
}

In my first draft I kept the cache inside the handler. The problem: IHttpClientFactory rebuilds the handler chain when the handler lifetime ends (2 minutes by default), and the cache in a transient handler goes with it. Moving the cache into a singleton provider left the handler stateless. Registration and a trial run:

services.AddSingleton<ClientCredentialsTokenProvider>();
services.AddTransient<ClientCredentialsHandler>();
services.AddHttpClient("token");
services.AddHttpClient("bookstore-api", client => client.BaseAddress = new Uri(Api))
    .AddHttpMessageHandler<ClientCredentialsHandler>();

// 20 concurrent calls, then 20 sequential calls
HttpClient api = factory.CreateClient("bookstore-api");
HttpResponseMessage[] parallel = await Task.WhenAll(Enumerable.Range(0, 20).Select(_ => api.GetAsync("/books")));
for (int i = 0; i < 20; i++)
{
    using HttpResponseMessage response = await factory.CreateClient("bookstore-api").GetAsync("/books");
    response.EnsureSuccessStatusCode();
}
Console.WriteLine($"40 calls, all 200: {parallel.All(r => r.IsSuccessStatusCode)}, token requests: {tokens.TokenRequests}");
$ dotnet run
40 calls, all 200: True, token requests: 1

Forty calls, one token request; the twenty concurrent calls shared a single request behind the SemaphoreSlim. If you don't want to write this yourself, Duende.AccessTokenManagement (4.3.0, Apache-2.0 licensed) does the same for client credentials and user tokens, and it can also produce DPoP proofs.

Sender-Constrained Tokens: DPoP

A bearer token is what its name says: whoever carries it uses it. That is the answer to the third question; a copied token works from anywhere until it expires. DPoP (Demonstrating Proof of Possession, RFC 9449) binds the token to a private key held by the client. On every request the client sends a short JWT signed with that key, the proof: which HTTP (Hypertext Transfer Protocol) method (htm), which address (htu), when (iat), a single-use identifier (jti) and the hash of the access token (ath). The authorization server writes the key's thumbprint into the token's cnf.jkt claim; the API checks that the proof was signed with that key. Whoever steals the token can do nothing without also stealing the key.

Making DPoP mandatory for a client in Keycloak is one attribute: "dpop.bound.access.tokens": "true". In the lab I gave it to a new client, bookstore-dpop-worker. A token request without a proof:

{"error":"invalid_request","error_description":"DPoP proof is missing"}

On the client side, producing a proof with JsonWebTokenHandler takes a few lines:

static string CreateProof(ECDsa key, string method, string url, string? accessToken)
{
    ECParameters p = key.ExportParameters(includePrivateParameters: false);
    Dictionary<string, object> jwk = new()
    {
        ["kty"] = "EC",
        ["crv"] = "P-256",
        ["x"] = Base64UrlEncoder.Encode(p.Q.X!),
        ["y"] = Base64UrlEncoder.Encode(p.Q.Y!)
    };
    Dictionary<string, object> claims = new()
    {
        ["htm"] = method,
        ["htu"] = url,
        ["iat"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
        ["jti"] = Guid.NewGuid().ToString()
    };
    if (accessToken is not null)
    {
        // ath: binds the proof to a specific access token
        claims["ath"] = Base64UrlEncoder.Encode(SHA256.HashData(Encoding.ASCII.GetBytes(accessToken)));
    }

    return new JsonWebTokenHandler { SetDefaultTimesOnTokenCreation = false }.CreateToken(new SecurityTokenDescriptor
    {
        TokenType = "dpop+jwt",
        AdditionalHeaderClaims = new Dictionary<string, object> { ["jwk"] = jwk },
        Claims = claims,
        SigningCredentials = new SigningCredentials(new ECDsaSecurityKey(key), SecurityAlgorithms.EcdsaSha256)
    });
}

The public key travels in the proof's header (jwk); the private key never leaves the client. With this proof on the token request, Keycloak returned a token_type of DPoP and wrote the key's thumbprint into the token:

token_type=DPoP cnf={"jkt":"dJnKHIhy6mRXrmXl8_EciyPO3eD8U77cl_zG4Fy50Yo","kc-jkt-type":"DPoP"}

The API side is harder: ASP.NET Core 10's JwtBearer handler has no built-in DPoP validation. Duende's Duende.AspNetCore.Authentication.JwtBearer package adds it (its NuGet metadata has no explicit license expression; check its terms before using it). To see the mechanics, I wrote the checks from sections 4.3 and 7.1 of RFC 9449 myself, in their smallest form. I add two events to JwtBearer: OnMessageReceived reads the Authorization: DPoP <token> scheme, OnTokenValidated validates the proof:

options.Events = new JwtBearerEvents
{
    // Also read the "Authorization: DPoP <token>" scheme
    OnMessageReceived = context =>
    {
        string? header = context.Request.Headers.Authorization;
        if (header?.StartsWith("DPoP ", StringComparison.Ordinal) == true)
        {
            context.Token = header["DPoP ".Length..];
        }
        return Task.CompletedTask;
    },
    OnTokenValidated = async context =>
    {
        DPoPProofValidator validator = context.HttpContext.RequestServices.GetRequiredService<DPoPProofValidator>();
        string? error = await validator.ValidateAsync(context.HttpContext, context.Principal!);
        if (error is not null)
        {
            context.Fail(error);
        }
    }
};
// Smallest form of RFC 9449 sections 4.3 and 7.1: proof signature, htm/htu, iat, jti replay, ath and cnf.jkt match
public sealed class DPoPProofValidator(IMemoryCache replayCache, TimeProvider clock)
{
    private static readonly TimeSpan MaxAge = TimeSpan.FromSeconds(60);

    public async Task<string?> ValidateAsync(HttpContext http, ClaimsPrincipal principal)
    {
        string? authorization = http.Request.Headers.Authorization;
        bool dpopScheme = authorization?.StartsWith("DPoP ", StringComparison.Ordinal) == true;
        string? cnf = principal.FindFirstValue("cnf");

        if (cnf is null)
        {
            // An unbound token cannot use the DPoP scheme (the reverse of a downgrade is rejected too)
            return dpopScheme ? "DPoP scheme used with an unbound token" : null;
        }
        if (!dpopScheme)
        {
            return "DPoP-bound token sent as Bearer";
        }

        string[] proofs = http.Request.Headers["DPoP"].ToArray()!;
        if (proofs.Length != 1)
        {
            return "exactly one DPoP header required";
        }

        JsonWebToken proof = new(proofs[0]);
        if (!proof.TryGetHeaderValue("jwk", out JsonElement jwkJson))
        {
            return "proof has no jwk header";
        }
        JsonWebKey jwk = new(jwkJson.GetRawText());
        if (jwk.HasPrivateKey)
        {
            return "proof jwk contains a private key";
        }

        TokenValidationResult result = await new JsonWebTokenHandler().ValidateTokenAsync(proofs[0], new TokenValidationParameters
        {
            IssuerSigningKey = jwk,
            ValidTypes = ["dpop+jwt"],
            ValidAlgorithms = [SecurityAlgorithms.EcdsaSha256, SecurityAlgorithms.RsaSha256],
            ValidateIssuer = false,
            ValidateAudience = false,
            ValidateLifetime = false,
            RequireExpirationTime = false
        });
        if (!result.IsValid)
        {
            return $"proof signature invalid: {result.Exception?.GetType().Name}";
        }

        string htu = $"{http.Request.Scheme}://{http.Request.Host}{http.Request.PathBase}{http.Request.Path}";
        if (proof.GetPayloadValue<string>("htm") != http.Request.Method || proof.GetPayloadValue<string>("htu") != htu)
        {
            return "proof htm/htu mismatch";
        }

        DateTimeOffset issuedAt = DateTimeOffset.FromUnixTimeSeconds(proof.GetPayloadValue<long>("iat"));
        if ((clock.GetUtcNow() - issuedAt).Duration() > MaxAge)
        {
            return "proof too old";
        }

        string jti = proof.GetPayloadValue<string>("jti");
        if (!replayCache.TryGetValue(jti, out _))
        {
            replayCache.Set(jti, true, MaxAge * 2);
        }
        else
        {
            return "proof replayed";
        }

        string accessToken = authorization!["DPoP ".Length..];
        string ath = Base64UrlEncoder.Encode(SHA256.HashData(Encoding.ASCII.GetBytes(accessToken)));
        if (proof.GetPayloadValue<string>("ath") != ath)
        {
            return "proof ath does not match the access token";
        }

        string jkt = Base64UrlEncoder.Encode(jwk.ComputeJwkThumbprint());
        string boundJkt = JsonDocument.Parse(cnf).RootElement.GetProperty("jkt").GetString()!;
        return jkt == boundJkt ? null : "proof key does not match cnf.jkt";
    }
}

Five scenarios, in my own lab, with my own token:

$ dotnet run -- dpop
token_type=DPoP cnf={"jkt":"dJnKHIhy6mRXrmXl8_EciyPO3eD8U77cl_zG4Fy50Yo","kc-jkt-type":"DPoP"}
DPoP + valid proof      -> 200
same proof again        -> 401
stolen token as Bearer  -> 401
proof from another key  -> 401
proof for another URL   -> 401

Bearer was not authenticated. Failure message: proof replayed
Bearer was not authenticated. Failure message: DPoP-bound token sent as Bearer
Bearer was not authenticated. Failure message: proof key does not match cnf.jkt
Bearer was not authenticated. Failure message: proof htm/htu mismatch

The most critical line is the third: someone who stole the token tries to send it as a classic Bearer, and the API rejects it because it knows from cnf that the token is bound. Skip that check and DPoP's whole benefit is gone. The fourth line is an attacker producing a proof with their own key, the fifth someone trying to reuse a proof produced for another endpoint.

mTLS (mutual TLS (Transport Layer Security), RFC 8705) applies the same idea at the TLS layer: the token is bound to the thumbprint of the client certificate, and the API looks at the certificate on the connection. It is practical for server-to-server traffic in organizations that already have a certificate infrastructure; hard in the browser and behind proxies that terminate TLS in front. DPoP works at the application layer, so it does not have these constraints.

In Production

DPoP's jti replay check uses IMemoryCache in the lab; with several API instances you need a shared cache, otherwise a proof used on one instance is accepted again on another. The htu comparison breaks behind a reverse proxy: the API sees itself as http://internal:8080 while the client signs the proof for https://api.bookstore.example. Configure forwarded headers correctly, or read the expected external address from configuration. RFC 9449 also defines the WWW-Authenticate: DPoP scheme in error responses and, if needed, a server-provided nonce; my example does neither.

Separate audiences per API and don't share them across environments; a test environment token must not pass the production API. The easiest way is a separate realm or tenant per environment. If you use introspection, cache its result, but limit the cache duration to the revocation delay you can tolerate; decide up front whether the API fails closed when the authorization server is unreachable.

There is also a warning I saw in Keycloak's log on every token request: Client 'bookstore-web' ... has 'Full scope allowed' switch enabled. This client switch is deprecated. With "Full scope allowed" on, all of the user's roles go into the client's token; it has to be turned off to limit the token to the roles the client actually needs. In my lab clients it stayed on by default.

Common Pitfalls

First, all APIs accepting the same audience, or ValidateAudience = false. Second, mistaking a scope for a user permission; the orders.read example above. Third, applying RequireClaim("scope", ...) to a space-separated value; it never matches, and hours go into "why is it always 403". Fourth, assuming a signed-out user's JWT becomes invalid immediately.

Fifth, fetching a client credentials token on every request, or keeping the cache in a transient handler. Sixth, setting up DPoP and still letting a bound token arrive as Bearer. Seventh, accepting an ID token or another API's token; the cure for all of them is the same: audience.

A Small Experiment

★☆☆ Delete the options.Audience line in the API and repeat the "no scope token" request. What was the response, and why? Then put the line back.

★★☆ Write a unit test for ClientCredentialsTokenProvider with FakeTimeProvider: with a 300-second token, verify that at second 269 it comes from the cache and at second 271 from a new request.

★★★ Add RFC 9449's server nonce to DPoPProofValidator: on the first request the API returns 401 with a DPoP-Nonce header, and the client retries with the nonce in the proof. Explain what the nonce changes against clock skew and pre-generated proof attacks.

The API now knows which token it accepts, from which client, for which user. In the next post we go back to the browser: the BFF (Backend for Frontend) pattern in which the SPA (Single-Page Application) never sees a token, the architecture RFC 10017 recommends, and adding the token on the server to the request that goes to the API with YARP (Yet Another Reverse Proxy).