What Is JWT and Why Does It Matter?
JSON Web Token (JWT) is an open standard (RFC 7519) for securely transmitting information between parties as a compact, URL-safe token. It is the dominant mechanism for stateless authentication in modern web APIs, single-page applications, and microservice architectures.
A JWT consists of three Base64URL-encoded segments joined by periods: a header (algorithm and token type), a payload (claims about the user and session), and a signature (cryptographic proof of integrity).
Important: Base64URL encoding is not encryption. The payload is readable by anyone who possesses the token. Never store sensitive data inside a JWT payload.
The Two-Token Architecture
The correct pattern uses two token types: a short-lived access token (JWT, 15 minutes, stored in JS memory) and a long-lived refresh token (opaque random string, 7 days, stored in an HttpOnly cookie).
A single long-lived JWT is a liability. If it is stolen, the attacker retains access for the token’s full remaining lifetime with no revocation path. The correct pattern uses two token types with distinct roles and storage requirements.
| Token | Format | Expiry | Storage | Purpose |
|---|---|---|---|---|
| Access Token | JWT (signed) | 15 minutes | JavaScript memory | Authenticate API requests |
| Refresh Token | Opaque random string | 7 days | HttpOnly cookie | Obtain a new access token |
The refresh token is stored as an HttpOnly, Secure, SameSite=Strict cookie. This combination means it cannot be read by JavaScript (XSS protection), is transmitted over HTTPS only, and is not sent in cross-site requests (CSRF protection). Only the SHA-256 hash of the token is written to the database — the raw value is never persisted.
// Refresh token delivered via HttpOnly cookie — never in the response body
Response.Cookies.Append("refreshToken", refreshToken, new CookieOptions
{
HttpOnly = true, // JavaScript cannot access this
Secure = true, // HTTPS only
SameSite = SameSiteMode.Strict, // CSRF protection
Expires = DateTimeOffset.UtcNow.AddDays(7),
Path = "/api/auth" // scoped to auth endpoints only
});
// Access token returned in the response body — stored in JS memory by the client
return Ok(new { accessToken, expiresIn = 900 });
On every refresh request, the existing refresh token is revoked and replaced — a pattern called token rotation. If a stolen refresh token is used by an attacker, the legitimate user’s next request fails because the token has already been consumed. The server detects the double-use as a compromise signal.
Signing Algorithms: RS256 vs PS256 vs HS256
The signing algorithm determines how the token’s integrity is guaranteed. Choosing the wrong one — or accepting any algorithm — is one of the most commonly exploited JWT vulnerabilities.
| Algorithm | Type | Recommended | Notes |
|---|---|---|---|
HS256 | Symmetric (HMAC) | Avoid in APIs | Single shared secret for signing and verification. If any service is compromised, all tokens are forgeable. |
RS256 | Asymmetric (RSA) | Acceptable | Private key signs; public key verifies. Safer for multi-service architectures. |
PS256 | Asymmetric (RSA-PSS) | Preferred | More secure variant of RS256. Recommended for new implementations. |
ES256 | Asymmetric (ECDSA) | Preferred | Smaller keys, faster verification. Best for high-throughput services. |
none | None | Never | Bypasses signature verification entirely. Must be explicitly rejected. |
// Explicitly whitelist allowed algorithms — never trust the token's alg header
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = publicKey,
ValidateIssuer = true,
ValidIssuer = "https://auth.yourdomain.com",
ValidateAudience = true,
ValidAudience = "https://api.yourdomain.com",
ValidateLifetime = true,
ClockSkew = TimeSpan.Zero,
// Whitelist — prevents alg:none and algorithm confusion attacks
ValidAlgorithms = ["PS256", "RS256"],
ValidTypes = ["JWT"]
};
The Algorithm Confusion Attack
The algorithm confusion attack is a well-documented JWT vulnerability. An attacker modifies the token header to change the algorithm from RS256 (asymmetric) to HS256 (symmetric), then signs the forged token using the server’s public key as the HMAC secret.
If the validation code trusts the algorithm field from the token header, it will attempt to verify the token using the public key as the HMAC secret — and because the attacker signed it with the same key, verification succeeds. The token is forged.
The defence is straightforward: validate the algorithm before calling ValidateToken(), and set an explicit algorithm whitelist in TokenValidationParameters.
C# — TokenService.cs — Algorithm pre-check
private static readonly string[] _allowed = ["PS256", "RS256"];
public ClaimsPrincipal? ValidateAccessToken(string token)
{
var handler = new JwtSecurityTokenHandler();
if (!handler.CanReadToken(token)) return null;
// Check algorithm BEFORE ValidateToken() — prevents confusion attacks
var raw = handler.ReadJwtToken(token);
if (!_allowed.Contains(raw.Header.Alg)) return null;
var principal = handler.ValidateToken(token, _params, out _);
// Reject identity tokens used as access tokens
if (principal.FindFirstValue("token_use") != "access") return null;
return principal;
}
Token Revocation Without a Blacklist
Stateless JWTs cannot be individually cancelled after issuance. The standard workaround — a token blacklist stored in Redis — adds a cache dependency to every authenticated request. A more efficient approach uses a token version claim.
Store an integer on the user record (e.g., TokenVersion). Embed it as a claim in every access token. On each request, middleware compares the token’s version claim against the current database value — cached in Redis for performance. When all tokens must be revoked (password change, suspected compromise, logout-all), increment the version. Every token carrying an older version is immediately rejected.
C# — RefreshTokenService.cs — Revoke all sessions
public async Task RevokeAllAsync(string userId, string ip)
{
// Revoke all active refresh tokens
var active = await _db.RefreshTokens
.Where(t => t.UserId == userId && !t.IsRevoked)
.ToListAsync();
foreach (var t in active)
{ t.IsRevoked = true; t.RevokedAt = DateTime.UtcNow; t.RevokedByIp = ip; }
// Increment token version — invalidates all outstanding access tokens immediately
await _db.Users
.Where(u => u.Id == userId)
.ExecuteUpdateAsync(u =>
u.SetProperty(x => x.TokenVersion, x => x.TokenVersion + 1));
await _db.SaveChangesAsync();
}
Key Management
The security of the entire JWT scheme depends on the confidentiality of the private signing key. A compromised private key allows an attacker to issue arbitrary tokens that pass all cryptographic validation.
Development: Store the private key using dotnet user-secrets. It remains in the OS user profile, outside the project directory and source control.
Production: Use a dedicated secrets manager — Azure Key Vault, AWS Secrets Manager, or HashiCorp Vault. The key never enters a config file or a repository.
Public key: Expose via a JWKS endpoint at /.well-known/jwks.json. Downstream services auto-discover the current verification key without any secret sharing.
The public key is designed to be public. Exposing it via JWKS is not a risk — it is the standard pattern. The private key is the secret. The public key is the verification tool.
Bash — Generate RSA-2048 key pair
# Generate private key and convert to PKCS#8 (.NET compatible)
openssl genrsa -out Keys/private_key.pem 2048
openssl pkcs8 -topk8 -inform PEM -outform PEM \
-in Keys/private_key.pem -out Keys/private_key_pkcs8.pem -nocrypt
# Extract public key — safe to commit and distribute
openssl rsa -in Keys/private_key_pkcs8.pem -pubout -out Keys/public_key.pem
# Store private key in dotnet user-secrets (development)
dotnet user-secrets set "JwtSettings:PrivateKey" "$(cat Keys/private_key_pkcs8.pem)"
JWT Security Checklist
The following checklist covers the requirements documented in this article. Each item maps to a specific attack vector or failure mode.
| Requirement | Rationale | Status |
|---|---|---|
| Algorithm explicitly whitelisted | Prevents alg:none and confusion attacks | Critical |
aud claim validated | Prevents cross-service token reuse | Critical |
iss claim validated | Prevents acceptance of foreign tokens | Critical |
ClockSkew = TimeSpan.Zero | Enforces strict expiry window | Critical |
| Access token expiry ≤ 15 min | Limits stolen token damage window | Critical |
| Refresh token in HttpOnly cookie | Prevents XSS token theft | Critical |
| Refresh token stored as SHA-256 hash | Protects against DB compromise | Required |
| Token rotation on every refresh | Detects stolen refresh tokens | Required |
| Token version claim + middleware | Enables instant revocation | Required |
| Private key in Key Vault / user-secrets | Prevents key leakage via source control | Critical |
| Public key exposed via JWKS endpoint | Standard multi-service key distribution | Required |
| No sensitive data in payload | Payload is not encrypted (Base64 only) | Critical |
Summary
JWT is a well-specified, widely supported authentication mechanism. Its security is not inherent — it is the product of deliberate implementation choices. The most impactful are: short access token expiry, refresh token rotation with HttpOnly cookie delivery, explicit algorithm whitelisting, full claim validation including iss and aud, and private key storage outside of source control.
Applied together, these patterns reduce the attack surface substantially and give the system predictable, controllable behaviour when tokens are compromised. None of them require advanced cryptography knowledge — only accurate, complete configuration.