JWT Bearer Tokens

What is inside a token, validating signature, issuer, audience and expiry, and why revoking one takes extra machinery.

Syntaxbuilder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(options => { options.TokenValidationParameters = ...; })

What a token is

A JSON Web Token is a short string in three parts joined by dots: header, payload and signature. The first two are JSON, Base64Url encoded, and anyone holding the token can read them. They are not secret. The third part is a signature over the first two, made with a key only the issuer has.

So a token proves two things: the claims inside came from that issuer, and nobody edited them. The payload uses registered short names: iss for issuer, aud for audience, sub for subject, exp for expiry as a Unix timestamp.

Validating one

The AddJwtBearer handler reads the Authorization: Bearer ... header and checks four things through TokenValidationParameters: the signature against your key, the issuer, the audience and the expiry. Switching any of them off is a real hole. A token signed by the right key but issued for a different API is still not for you.

AddJwtBearer() with no name is shorthand for AddJwtBearer(JwtBearerDefaults.AuthenticationScheme), and that constant is the string "Bearer".

Against the program below, curl -i http://localhost:5000/me returns 401 with a WWW-Authenticate: Bearer header. The same request with -H "Authorization: Bearer TOKEN", for a token this configuration accepts, returns 200 and the body u-1042.

Revocation

Validation needs no database call, which is the appeal. It is also why a token cannot be withdrawn: there is no server-side record to change. Signing a user out leaves their token working until exp passes. The usual answers are short lifetimes plus a refresh token, or a token id checked against a denylist, which puts the database call back.

Example

Example · csharp
using System.Security.Claims;
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;

var builder = WebApplication.CreateBuilder(args);

string key = builder.Configuration["Jwt:Key"]
    ?? throw new InvalidOperationException("Jwt:Key is not configured.");

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        // Keep the short claim names from the token, such as "sub".
        options.MapInboundClaims = false;
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuerSigningKey = true,
            IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key)),
            ValidateIssuer = true,
            ValidIssuer = "https://auth.example.com",
            ValidateAudience = true,
            ValidAudience = "orders-api",
            ValidateLifetime = true,
            ClockSkew = TimeSpan.FromSeconds(30)
        };
    });
builder.Services.AddAuthorization();

var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();

app.MapGet("/me", (ClaimsPrincipal user) =>
    user.FindFirst("sub")?.Value ?? "(no sub claim)").RequireAuthorization();

app.Run();

When to use it

  • Protect an HTTP API that is called by a mobile app or another service rather than a browser.
  • Work out why a token that validates in one environment is rejected in another.
  • Add an immediate sign-out to a service that currently issues long-lived tokens.
  • Decide between a short access token with a refresh token and a server-side denylist.

More examples

Reading a token's two JSON parts

Run this the first time someone suggests putting a user's email address or an internal flag in a token to hide it. The payload is encoding, not encryption. It is also the quickest way to see why a token is rejected: read the aud and exp values before blaming the key.

Example · csharp
using System.Text;

// A JWT is three Base64Url parts joined by dots. Nothing here checks the
// signature, which is the point: anyone can read a token's contents.
const string token =
    "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." +
    "eyJzdWIiOiJ1LTEwNDIiLCJhdWQiOiJvcmRlcnMtYXBpIiwiZXhwIjoxNzY3MjI1NjAwfQ." +
    "c2lnbmF0dXJlLWJ5dGVz";

string[] parts = token.Split('.');
Console.WriteLine(Decode(parts[0]));
Console.WriteLine(Decode(parts[1]));

static string Decode(string part)
{
    // Base64Url swaps + for - and / for _, and drops the = padding.
    string padded = part.Replace('-', '+').Replace('_', '/')
        .PadRight(part.Length + (4 - part.Length % 4) % 4, '=');
    return Encoding.UTF8.GetString(Convert.FromBase64String(padded));
}

// Output:
// {"alg":"HS256","typ":"JWT"}
// {"sub":"u-1042","aud":"orders-api","exp":1767225600}

Issuing a token

The issuing side of the same four values the validator checks: Issuer, Audience, Expires and the signing key. Keep the expiry short, minutes rather than days, because this is the only thing that limits the damage of a leaked token in a design with no denylist.

Example · csharp
using System.Security.Claims;
using System.Text;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;

namespace Auth;

public sealed class TokenIssuer
{
    private readonly SigningCredentials _credentials;

    // HMAC-SHA256 needs a key of at least 256 bits, so at least 32 bytes.
    public TokenIssuer(string key)
    {
        var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key));
        _credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
    }

    public string Create(string userId, DateTime expiresUtc)
    {
        var descriptor = new SecurityTokenDescriptor
        {
            Issuer = "https://auth.example.com",
            Audience = "orders-api",
            Subject = new ClaimsIdentity(new List<Claim> { new("sub", userId) }),
            Expires = expiresUtc,
            SigningCredentials = _credentials
        };

        return new JsonWebTokenHandler().CreateToken(descriptor);
    }
}

Making revocation possible

The extra machinery referred to in the lesson. It restores an immediate sign-out, and gives up the main advantage of bearer tokens by adding a lookup to every request, so the denylist entries only need to live as long as the token's remaining lifetime.

Example · csharp
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.Caching.Distributed;

// Program.cs (excerpt). Every issued token carries a unique id in its
// "jti" claim. Signing out writes that id to a shared store; this event
// rejects any token whose id is listed.
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.MapInboundClaims = false;   // so "jti" stays called "jti"
        options.Events = new JwtBearerEvents
        {
            OnTokenValidated = async context =>
            {
                var cache = context.HttpContext.RequestServices
                    .GetRequiredService<IDistributedCache>();

                string? jti = context.Principal?.FindFirst("jti")?.Value;

                if (jti is not null && await cache.GetAsync($"revoked:{jti}") is not null)
                {
                    context.Fail("Token has been revoked.");
                }
            }
        };
    });

Discussion

  • Be the first to comment on this lesson.