A JWT is sealed against tampering, not sealed against reading — and mixing those two up is the single most consequential mistake you can make with it.
Paste any JWT you've ever seen into a plain base64 decoder — no library, no secret key, nothing special required — and its payload comes back out as ordinary, readable JSON. Try it with a real one right now if you have one handy. This often catches people off guard the first time: a JWT looks like a long, opaque, cryptographic-looking blob of characters, so it's tempting to assume it's encrypted, sealed away from anyone who doesn't hold some secret key. It is not. A standard JWT is signed, not encrypted, by default — and that single fact, understood precisely, is the most important thing in this entire lesson.
The previous lesson introduced token-based authentication conceptually: the client sends a token, typically in an Authorization: Bearer ... header, and the server trusts what's inside it. This lesson is about the specific, extremely common token format that plays that role in modern APIs — JSON Web Tokens — exactly what's inside one, why it's genuinely valuable despite being readable by anyone who holds it, and how to configure ASP.NET Core to verify one on every request.
In this lesson: the precise three-part JWT structure, the signed-vs-encrypted distinction and why it matters, why JWTs enable real stateless authentication, configuring AddJwtBearer(), and expiration plus refresh tokens at a conceptual level.
A JWT (JSON Web Token) is a compact, text-based token that carries a set of claims — facts about an identity, exactly the concept from the previous lesson — packaged together with a cryptographic signature. The server that issues it signs it with a secret (or private) key it controls; any server that trusts that key can later verify the signature is genuine and hasn't been altered, without needing to look anything up in a database.
A JWT is a string made of exactly three base64url-encoded segments, separated by dots: header.payload.signature.
{"alg":"HS256","typ":"JWT"}).Base64url encoding is not encryption — it's a reversible text encoding, exactly like ordinary base64, just using a URL-safe character set. Decoding it requires no key, no secret, nothing beyond a decoder anyone can run in seconds.
Traditional server-side sessions store the "who is this" information on the server, keyed by a session ID the client holds. That means every request needs a lookup — hit a database, or a shared cache — just to figure out who's calling, and that shared state has to be reachable from every server instance handling traffic, which gets genuinely awkward once an API is running behind a load balancer across many machines or scaling elastically.
What's needed is a way for a client to carry proof of its own identity, self-contained, so that any server instance — with no shared session store, no database round-trip — can independently verify that proof and trust the claims inside it on every request.
JWTs. Because the token is cryptographically signed, any server holding the corresponding verification key can check that signature locally — pure computation, no network call, no shared state — and, if it's valid, trust every claim inside without asking anyone else to confirm it. This is genuinely called stateless authentication: the server issuing the token doesn't need to remember it afterward, and any server verifying it doesn't need to ask the issuer whether it's still good (short of checking expiration and, if used, a revocation list).
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI4ODI3Iiwicm9sZSI6IkFkbWluIn0.4f8a2c...{"alg":"HS256","typ":"JWT"} — plain, readable JSON{"sub":"8827","role":"Admin"} — plain, readable JSON, no key requiredNotice: two of the three segments decode with zero effort and zero secrets. Only the third segment — the signature — is where the cryptographic key comes into play, and even then, only to produce or verify it, never to hide the other two.
var claims = new[]
{
new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
new Claim(ClaimTypes.Role, "Admin")
};
var token = new JwtSecurityToken(
issuer: "https://api.myapp.com",
audience: "myapp-clients",
claims: claims,
expires: DateTime.UtcNow.AddMinutes(15), // short-lived access token — see below
signingCredentials: new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256));
string jwt = new JwtSecurityTokenHandler().WriteToken(token);
GET /api/orders
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI4ODI3In0.4f8a2c...
AddJwtBearer) recomputes the expected signature using the same signing key and compares it to the one on the token. It also checks the exp (expiration) claim against the current time.ClaimsPrincipal is built directly from them — the exact mechanism from the authentication lesson, now with a concrete source for the claims.Configuring ASP.NET Core to accept and verify JWTs — the full AddJwtBearer setup, expanding on what the authentication lesson previewed:
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = "https://api.myapp.com",
ValidateAudience = true,
ValidAudience = "myapp-clients",
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(builder.Configuration["Jwt:SigningKey"]!)),
ValidateLifetime = true, // reject an expired token — never disable this
ClockSkew = TimeSpan.FromSeconds(30) // small tolerance for clock drift between servers
};
});
Meaning: Every one of these Validate... flags is a real check the handler performs on every incoming request — issuer, audience, signature, and expiration are all verified before the request is trusted at all. ValidateLifetime in particular must never be turned off: it's what actually enforces that an expired token stops working.
Access tokens and refresh tokens, working together — the standard pattern that keeps the "stateless, no database round-trip" benefit while limiting how much damage a leaked token can do:
// Login endpoint issues BOTH tokens:
public record TokenResponse(string AccessToken, string RefreshToken);
[AllowAnonymous]
[HttpPost("login")]
public IActionResult Login(LoginRequest request)
{
// ... verify credentials ...
var accessToken = _tokenService.CreateAccessToken(user, TimeSpan.FromMinutes(15)); // short-lived
var refreshToken = _tokenService.CreateRefreshToken(user, TimeSpan.FromDays(30)); // longer-lived,
// stored server-side so it CAN be revoked
return Ok(new TokenResponse(accessToken, refreshToken));
}
// Refresh endpoint — exchanges a still-valid refresh token for a brand-new access token:
[AllowAnonymous]
[HttpPost("refresh")]
public IActionResult Refresh(RefreshRequest request)
{
if (!_tokenService.TryValidateRefreshToken(request.RefreshToken, out var user))
return Unauthorized();
var newAccessToken = _tokenService.CreateAccessToken(user, TimeSpan.FromMinutes(15));
return Ok(new { AccessToken = newAccessToken });
}
The access token is the short-lived JWT actually sent on every API request in the Authorization header — kept deliberately short-lived (minutes, not days) specifically so that if it's ever leaked, the exposure window is small. The refresh token is longer-lived, used only against the dedicated refresh endpoint to obtain a fresh access token, and (unlike a typical stateless access token) is commonly tracked server-side precisely so it can be revoked — logging a user out everywhere, or reacting to a suspected compromise, becomes possible again for the one token that's actually stored.
A JWT is like a letter written on ordinary paper, folded and closed with an official wax seal pressed into it. Anyone who picks up the letter can unfold it and read every word — nothing about the seal stops that. What the seal proves is that the letter genuinely came from whoever holds that specific seal stamp, and that nobody has altered a single word since it was sealed — break the seal to tamper with the contents, and it's obvious the seal no longer matches.
A locked safe is a completely different thing: it keeps its contents hidden from anyone without the combination. A JWT is the wax-sealed letter, not the safe. If a message genuinely needs to stay hidden from whoever's holding it, a JWT payload is the wrong tool entirely — you'd need actual encryption on top, which is a different, separate mechanism from what a standard signed JWT provides.
This is worth restating directly: a standard JWT's payload is readable by anyone holding the token. The visual density of the string — long, seemingly random-looking characters — has nothing to do with confidentiality; it's just the character set base64url happens to use. Decoding it back to plain JSON takes a decoder, not a key. If you need genuine confidentiality for the token's contents, that requires a separate mechanism (an encrypted JWT variant, JWE, or simply not putting sensitive data in the token at all) — not something a plain signed JWT provides.
Because verification is purely local (no lookup against the issuer), a standard JWT remains valid to any verifier until its exp claim passes — even if the server would very much like to revoke it right now (a user logs out, an account is compromised). This is a genuine, well-understood trade-off of the stateless design, not a misunderstanding to wave away. It's exactly why access tokens are kept deliberately short-lived, and why the refresh-token pattern exists: it reintroduces a server-side, revocable component (the refresh token) specifically to bound how long a compromised or logged-out session can keep working.
Embedding a password, a full credit card number, or an internal secret as a claim, reasoning "it's signed, so it's safe."
Signed means tamper-evident, not confidential. Only put data in a JWT payload that's acceptable for the token holder — and anyone who intercepts it — to simply read.
Issuing an access token that's valid for weeks or months "for convenience," which turns any single leaked token into a long-lived, essentially unrevocable credential.
Keep access tokens short-lived and use a refresh token for renewing them — this is the standard pattern precisely because it limits the blast radius of a leaked access token.
ValidateLifetime "just to make testing easier"Disabling expiration validation, even temporarily, in a codebase that later ships that configuration unchanged to production — an expired token would then be accepted indefinitely.
ValidateLifetime should always be true in any environment that could plausibly become, or leak into, production.
header.payload.signature — carrying claims plus a cryptographic signature.AddJwtBearer() configures ASP.NET Core to validate a token's issuer, audience, signature, and lifetime on every request — ValidateLifetime should always stay enabled.You've seen the exact structure of a JWT, and — most importantly — precisely what its signature does and doesn't protect. Let's check your understanding.
1. An attacker intercepts a valid JWT in transit but does not have the server's signing key. What can they do with it?
Correct: B
Why B is correct: The payload is base64url-encoded, not encrypted — anyone holding the token, key or no key, can decode and read it. What they cannot do without the signing key is produce a modified payload with a signature that still verifies correctly.
Why A is incorrect: This is exactly the misconception the lesson warns against — a JWT is signed, not encrypted, so its contents are readable without any key.
Why C is incorrect: Both the header and the payload are plain base64url-encoded JSON — equally readable, with no key required for either.
Why D is incorrect: Any legitimate verifier (not just the original issuer) checks the signature on every request — modifying the payload without the signing key invalidates the signature, and the modified token is rejected wherever it's verified.
Reinforcement: Signed = tamper-evident when verified. Not encrypted = readable by anyone holding it, always.
2. A developer stores a user's full home address as a claim in a JWT payload, reasoning "it's signed, so it's protected." What's wrong with this reasoning?
Correct: C
Why C is correct: This is the central security fact of the lesson. Signing proves the payload hasn't been altered since issuance — it never hides the payload's contents from anyone holding the token.
Why A is incorrect: This restates the exact misconception the lesson is built to correct — signed and encrypted are not the same property.
Why B is incorrect: There's no such strict built-in size limit relevant here — the actual problem is confidentiality, not payload size.
Why D is incorrect: An address is easily representable as JSON — the issue has nothing to do with data format.
Reinforcement: Whether or not something belongs in a JWT payload should be decided by "is this okay to be read by anyone with the token," never by "is this signed."
3. Why do JWTs enable "stateless" authentication?
Correct: A
Why A is correct: Signature verification is pure computation with a key the verifying server already has — no shared session store, no per-request database lookup, which is exactly what "stateless" means in this context.
Why B is incorrect: JWTs absolutely should expire — the lesson explicitly stresses keeping ValidateLifetime enabled and access tokens short-lived; "stateless" is unrelated to whether a token expires.
Why C is incorrect: The payload is not encrypted, and encryption isn't what makes the design stateless in the first place — local signature verification is.
Why D is incorrect: JWTs are commonly sent in an Authorization header, not exclusively stored in cookies — and storage location is unrelated to statelessness.
Reinforcement: "Stateless" refers to the verifying server not needing to consult shared state (a session store or database) to trust the token — a direct consequence of local, key-based signature verification.
4. What is the purpose of pairing a short-lived access token with a longer-lived refresh token, rather than issuing one long-lived access token?
Correct: B
Why B is correct: A short-lived access token bounds the damage if it's ever leaked — it simply stops working soon. The refresh token, often tracked server-side, is where revocation (logging a user out everywhere, reacting to a compromise) becomes possible again, since a stateless access token alone can't be individually revoked before it expires.
Why A is incorrect: This pattern doesn't change whether the access token is signed or encrypted — it's still a standard signed JWT.
Why C is incorrect: The access token's signature is still validated on every request exactly as before — this pattern doesn't remove that check.
Why D is incorrect: The access token still expires — in fact its short expiration is the entire point of the pattern, not something it skips.
Reinforcement: The access/refresh token split is specifically about balancing the stateless-scalability benefit against the very real trade-off that a stateless token can't be revoked before it naturally expires.
You now know exactly what a JWT protects — and, critically, what it never protected in the first place. Next up: OAuth 2.0 and OpenID Connect, where JWTs reappear as the concrete format of an OIDC identity token.
dotnetmadeeasy.com — Learn C# and .NET, the right way.