← Open in the full interactive course (progress tracking, search & more)

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.

What Is It?

The Simple Explanation

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.

The Technical Definition

A JWT is a string made of exactly three base64url-encoded segments, separated by dots: header.payload.signature.

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.

What signing actually gives you

What signing does NOT give you

Why Does It Exist?

The Problem — Session Lookups Don't Scale Cleanly Across Many Servers

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.

The Need

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.

The Solution

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).

Big Picture

THE THREE SEGMENTS, DECODED
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI4ODI3Iiwicm9sZSI6IkFkbWluIn0.4f8a2c...
↓ base64url-decode each segment ↓

Header{"alg":"HS256","typ":"JWT"} — plain, readable JSON
Payload{"sub":"8827","role":"Admin"} — plain, readable JSON, no key required
Signature → raw bytes; this part is what actually requires the signing key — to produce, and to verify

Notice: 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.

How It Works

ISSUING AND VERIFYING A JWT — STEP BY STEP
1. CLIENT AUTHENTICATES (e.g. submits a valid username/password)
2. SERVER ISSUES A SIGNED JWT
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);
3. CLIENT STORES IT AND SENDS IT ON EVERY SUBSEQUENT REQUEST
GET /api/orders
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI4ODI3In0.4f8a2c...
4. SERVER VERIFIES THE SIGNATURE AND EXPIRATION — NO DATABASE LOOKUP NEEDED

Simple Example

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.

The single most important security fact in this lesson: Never put a password, an API secret, a credit card number, or anything else meant to stay confidential into a JWT payload. Base64url is not encryption. Anyone who holds the token — including the legitimate client it was issued to, and anyone the token happens to be intercepted by — can decode the payload and read it in seconds, with no key required. The signature proves the payload wasn't tampered with; it says nothing whatsoever about who is allowed to read it.

Real-World Example

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.

Analogy

A sealed wax stamp on an open letter, not a locked safe

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.

Under the Hood

HOW THE SIGNATURE ACTUALLY GETS VERIFIED
HMAC vs. ASYMMETRIC SIGNING

Common Confusion

1. "It's encoded and looks like gibberish, so it must be encrypted" — no; base64url is not encryption

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.

2. "A signed JWT can't be revoked, so it's inherently insecure" — it's a real trade-off, not a flaw to ignore

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.

Common Mistakes

Mistake 1 — Putting sensitive data directly in the payload

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.

Mistake 2 — Setting an extremely long (or missing) expiration to avoid dealing with refresh logic

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.

Mistake 3 — Turning off 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.

When Should I Use It?

Rule of thumb: Before adding anything to a JWT payload, ask: "would I be comfortable with this value showing up in a public paste, in plain text, right now?" If not, it doesn't belong in the payload.

Mental Model

header.payload.signature — three base64url segments, dot-separated.
Signed = tamper-evident. Not encrypted = readable by anyone holding the token.
Verification = pure local computation with a key — no database round-trip, which is what "stateless" actually means here.
Access token = short-lived, sent on every request. Refresh token = longer-lived, revocable, used only to mint new access tokens.

Remember: If you ever catch yourself thinking "it's fine, it's signed" about something confidential — stop. Signed is not the same word as encrypted, and a JWT's payload is exactly as readable as an open letter with a wax seal on it.

Key Takeaway


Check Your Understanding

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?

Show answer

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?

Show answer

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?

Show answer

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?

Show answer

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.