Skip to content
ExponaBLOG
.NET & ASP.NET CORE · DAY 028 / 100

JWT authentication in ASP.NET Core: a step-by-step guide

The minimal correct setup for a stateless API.

Expona Admin6 min read

Stateless APIs need a way to prove a user is who they claim to be—without storing session data on the server. JWT authentication in ASP.NET Core gives you that. Done poorly, it leaks sensitive data or becomes impossible to revoke. Done right, it's elegant, scalable, and secure.

Why JWT authentication matters for stateless APIs

When you build a distributed system—multiple API servers, microservices, or a mobile-first architecture—you can't rely on server-side sessions. A user logs in on server A, but the next request hits server B. Where's the session? Nowhere. You need a token that proves identity without the server needing to look anything up.

JWT (JSON Web Token) is a standard for encoding claims into a signed string. The token itself is proof. No database lookup, no session store. But "stateless" doesn't mean "unsecure"—it means you have to get the crypto and the claims right.

Register the scheme — Validate issuer, audience, lifetime and signing key. All four.

Before your API accepts any JWT, you must tell ASP.NET Core how to validate it. This happens in Program.cs during the authentication scheme setup. You're not just checking that a token is signed; you're checking who signed it, who it's for, and how long it's valid.

Here's the minimal correct setup:

csharp
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(cfg["Jwt:SigningKey"]));

builder.Services.AddAuthentication("Bearer")
    .AddJwtBearer(o =>
    {
        o.TokenValidationParameters = new()
        {
            ValidIssuer = cfg["Jwt:Issuer"],
            ValidAudience = cfg["Jwt:Audience"],
            IssuerSigningKey = key,
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true,
            ValidateIssuerSigningKey = true,
            ClockSkew = TimeSpan.FromSeconds(30)
        };
    });

ValidIssuer: The server that issued the token. In a monolith, this is your domain. In a microservices setup, maybe your auth service. An attacker can't fake this—they'd need your signing key.

ValidAudience: Who the token is for. If your token says audience is "api.internal" but a client tries to use it on "mobile.app", validation fails. This prevents tokens from being misused across different systems.

IssuerSigningKey: The secret used to sign tokens. Store this in a secure config provider (Azure Key Vault, AWS Secrets Manager), never in code or plain-text config files.

ClockSkew: Tolerance for clock drift between servers. 30 seconds is standard. Without this, time-sync issues cause legitimate tokens to be rejected.

All four validations should be explicit. Don't let ASP.NET Core defaults hide your assumptions. I've seen teams set ValidateIssuer = false to "fix" a validation error, then wonder why their API accepts tokens from anywhere.

Short-lived access tokens — 15 minutes is a good default. Long-lived tokens cannot be revoked.

An access token should live as long as you're comfortable with it in the wild. 15 minutes is a reasonable default. If a token is stolen, the damage window is small.

The temptation is to make them last longer—fewer refresh calls, simpler for clients. But here's the catch: once a token is issued, you can't revoke it. If you need to log someone out, remove admin access, or respond to a security incident, long-lived tokens are already out there, still valid.

The proper pattern is short-lived access tokens paired with longer-lived refresh tokens. A refresh token is stored securely (usually as an HTTP-only cookie or in a mobile secure store) and is used only to request a new access token.

csharp
var claims = new List<Claim>
{
    new(ClaimTypes.NameIdentifier, userId.ToString()),
    new(ClaimTypes.Email, userEmail),
    new("tenant", tenantId.ToString())
};

var token = new JwtSecurityToken(
    issuer: cfg["Jwt:Issuer"],
    audience: cfg["Jwt:Audience"],
    claims: claims,
    expires: DateTime.UtcNow.AddMinutes(15),
    signingCredentials: new SigningCredentials(key, SecurityAlgorithms.HmacSha256)
);

var tokenString = new JwtSecurityTokenHandler().WriteToken(token);

Here, the token expires in 15 minutes. The client stores this in memory (for a web app, in a variable; for mobile, in RAM). When it expires, the client uses the refresh token to get a new one. This flow also gives you revocation: the refresh token is stored server-side (in a database or cache), so you can invalidate it anytime.

Keep claims small — User ID, roles, tenant. Not the whole user profile — tokens travel on every request.

Every HTTP request to your API includes the token in the Authorization header. Every request. If your token is 5 KB of JSON containing the user's full name, email, phone, address, profile picture URL, and settings, you've just added 5 KB overhead to every single request across your entire system.

The token's job is authentication and authorization. Put minimal claims in it:

  • User ID: So your API knows who the request is from.
  • Roles or permissions: For authorization checks.
  • Tenant ID: If you're multi-tenant, include this so you never accidentally mix tenants.

That's it. Everything else—user name, contact info, preferences—fetch from your database when you need it, or cache it separately.

csharp
var claims = new List<Claim>
{
    new(ClaimTypes.NameIdentifier, userId.ToString()),
    new(ClaimTypes.Role, "admin"),
    new("tenant", tenantId.ToString())
};

Compare that to including the entire user record. The difference in network traffic and token size compounds across millions of requests. In one system I worked on, we went from 2 KB tokens to 8 KB by including unnecessary claims. At 50,000 requests per second, that's 300 MB/s of wasted bandwidth.

Rotate signing keys — Support multiple keys with a key ID so you can rotate without logging everyone out.

A signing key can be compromised. When that happens, you need to roll to a new key without invalidating every active token. The trick is supporting multiple valid keys at once.

Include a kid (key ID) header in your token. When issuing new tokens, use the current key. When validating, accept any key in your active set. Then, gradually retire old keys.

csharp
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256)
{
    Kid = "key-2024-01"
};

var token = new JwtSecurityToken(
    issuer: cfg["Jwt:Issuer"],
    audience: cfg["Jwt:Audience"],
    claims: claims,
    expires: DateTime.UtcNow.AddMinutes(15),
    signingCredentials: credentials
);

On the validation side, maintain a dictionary of valid keys by ID:

csharp
var signingKeys = new[]
{
    new SymmetricSecurityKey(Encoding.UTF8.GetBytes(cfg["Jwt:SigningKey:Current"])),
    new SymmetricSecurityKey(Encoding.UTF8.GetBytes(cfg["Jwt:SigningKey:Previous"]))
};

builder.Services.AddAuthentication("Bearer")
    .AddJwtBearer(o =>
    {
        o.TokenValidationParameters = new()
        {
            ValidIssuer = cfg["Jwt:Issuer"],
            ValidAudience = cfg["Jwt:Audience"],
            IssuerSigningKeys = signingKeys,
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true,
            ValidateIssuerSigningKey = true
        };
    });

This way, tokens signed with either key are valid. As the old key reaches end-of-life (after token lifetime expires), remove it. New tokens use the current key. No users are logged out; the rotation is transparent.

Common mistakes

  • Storing secrets in code or appsettings.json: Your signing key belongs in Azure Key Vault, AWS Secrets Manager, or HashiCorp Vault. Commit a reference to the secret name, not the secret itself.
  • Not validating all four parameters: Leaving ValidateIssuer or ValidateAudience as false is a security gap. Every validation should be explicit.
  • Making tokens too long-lived: A 7-day access token is not a good idea. If the token is stolen, you're exposed for a week. Use short-lived tokens and refresh tokens.
  • Putting sensitive data in claims: A JWT is not encrypted (it's signed). Anyone can base64 decode it and read the claims. Don't put passwords, SSNs, or API keys in there.
  • Ignoring token expiration on the client: If your client blindly sends a token without checking its expiration time, it might send an expired token and waste a server request. Decode and check before sending.
  • Forgetting to implement refresh token rotation: If a refresh token is stolen, the attacker can get unlimited access tokens. Rotate refresh tokens on each use (issue a new one, retire the old one).

Key takeaways

  • Register the JWT bearer scheme with all four validations: issuer, audience, lifetime, and signing key. Don't disable any of them.
  • Use short-lived access tokens (15 minutes) paired with secure refresh tokens for proper revocation and security.
  • Keep claims minimal: user ID, roles, tenant. Let the token prove identity; fetch the rest from the database.
  • Support multiple signing keys with key IDs so you can rotate without logging everyone out.
  • Store secrets in a proper vault, never in code.
  • Remember: a JWT is signed, not encrypted. Anyone can read the claims—they just can't forge them without the key.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles