JWT authentication and authorization
What is inside a JWT, how the API validates one, the clock skew nobody remembers, and the line between a 401 and a 403.
After this lesson you can
- Decode a JWT and say why its payload is not secret
- List the checks JWT bearer authentication makes, in a sensible order
- Explain the default clock skew and token revocation trade-offs
- Distinguish 401 from 403 and authentication from authorization
Authentication establishes who the caller is and produces
HttpContext.User, a ClaimsPrincipal. Authorization decides
whether that principal may do this. When authentication fails or is
missing, an [Authorize] endpoint answers 401 (a challenge). When
the caller is known but a policy says no, it answers 403 (forbid).
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(o =>
{
o.Authority = "https://id.shop"; // issuer; signing keys come from its metadata
o.Audience = "shop-api";
});
builder.Services.AddAuthorization(o =>
o.AddPolicy("Admin", p => p.RequireRole("admin")));
app.UseAuthentication();
app.UseAuthorization();
app.MapDelete("/products/{id}", Delete).RequireAuthorization("Admin");
What a JWT is
Three base64url segments joined by dots: a header (alg, typ), a
payload of claims (sub, iss, aud, exp, nbf, ...; times are
seconds since the Unix epoch) and a signature over the first two
segments. Base64url is an encoding, not encryption: anyone holding the
token can read the payload.
using System.Text; public static class Solution{ static string B64Url(string json) => Convert.ToBase64String(Encoding.UTF8.GetBytes(json)).TrimEnd('=').Replace('+', '-').Replace('/', '_'); static string FromB64Url(string s) { s = s.Replace('-', '+').Replace('_', '/'); s = s.PadRight(s.Length + (4 - s.Length % 4) % 4, '='); return Encoding.UTF8.GetString(Convert.FromBase64String(s)); } public static List<string> Run() { var header = B64Url("{\"alg\":\"HS256\",\"typ\":\"JWT\"}"); var payload = B64Url("{\"sub\":\"42\",\"role\":\"admin\",\"exp\":1700000600}"); var token = header + "." + payload + ".signature-goes-here"; var parts = token.Split('.'); return new List<string> { "segments: " + parts.Length, FromB64Url(parts[0]), FromB64Url(parts[1]) }; }}With HS256 the signature is an HMAC-SHA256 using a secret shared by issuer and API. With RS256 or ES256 the issuer signs with a private key and the API only needs the public key, which the JWT bearer handler fetches from the authority's metadata. Changing one character of the payload invalidates the signature:
using System.Security.Cryptography;using System.Text; public static class Solution{ static string B64Url(byte[] bytes) => Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); static string Sign(string signingInput, byte[] key) => B64Url(HMACSHA256.HashData(key, Encoding.UTF8.GetBytes(signingInput))); static bool Verify(string token, byte[] key) { var parts = token.Split('.'); var expected = Encoding.ASCII.GetBytes(Sign(parts[0] + "." + parts[1], key)); return CryptographicOperations.FixedTimeEquals(expected, Encoding.ASCII.GetBytes(parts[2])); } public static List<string> Run() { var key = Encoding.UTF8.GetBytes("test-secret-not-for-production-123456"); var header = B64Url(Encoding.UTF8.GetBytes("{\"alg\":\"HS256\",\"typ\":\"JWT\"}")); var payload = B64Url(Encoding.UTF8.GetBytes("{\"sub\":\"42\",\"role\":\"user\"}")); var token = header + "." + payload + "." + Sign(header + "." + payload, key); var forgedPayload = B64Url(Encoding.UTF8.GetBytes("{\"sub\":\"42\",\"role\":\"admin\"}")); var parts = token.Split('.'); var forged = parts[0] + "." + forgedPayload + "." + parts[2]; var wrongKey = Encoding.UTF8.GetBytes("a-different-secret-000000000000000000"); return new List<string> { "original: " + Verify(token, key), "role changed to admin: " + Verify(forged, key), "checked with another key: " + Verify(token, wrongKey), }; }}What the API checks
The algorithm and the signature come first, because nothing in an
unverified payload can be trusted: the API accepts only algorithms it
expects (a token whose header says "alg": "none" must be rejected)
and verifies the signature with its key. Then come lifetime (exp,
nbf), issuer and audience. TokenValidationParameters.ClockSkew
defaults to five minutes, so a token is still accepted for up to
five minutes after its exp. Tests that expect a one-minute token to
fail after one minute will be surprised.
A JWT cannot be revoked by the API alone: it is valid until it expires. Keep access tokens short-lived and use refresh tokens, or add a server-side check (such as a deny list) for the cases that need immediate revocation.
Common mistakes
- Putting secrets or personal data in the payload because it "looks encrypted".
UseAuthorization()beforeUseAuthentication(): the user is still anonymous when the default policy is checked, so valid tokens get 401.- Returning 401 when an authenticated user lacks a role. That tells the client to log in again, which will not help; it is a 403.
- Comparing signatures with
==on strings in hand-written code. Use a constant-time comparison such asCryptographicOperations.FixedTimeEquals.
What interviewers ask
- Walk me through validating a JWT. Split and decode; verify the
signature with the expected algorithm and key; then
exp/ with skew, issuer and audience.
Exercise · Pro
Validate an HS256 token
Exercises are free in the first 3 lessons of every course. From here on they are part of Pro and Sprint; the lesson and its runnable examples stay open.
Upgrade