# 7 Common JWT Authentication Mistakes in ASP.NET Core (And How to Fix Them)

JWT authentication is one of those topics where the basics feel approachable but the production edge cases bite hard. Most ASP.NET Core APIs get the happy path working in an afternoon — and then spend weeks debugging silent 401s, token leakage, and authorization gaps that only surface under real conditions.

The patterns that break production JWTs are well-documented once you know what to look for. The full implementation — with refresh token rotation, token revocation, and a working test suite — is available on [Patreon](https://www.patreon.com/CodingDroplets), ready to pull and adapt for your own API.

Understanding why these mistakes happen in isolation is useful — seeing how JWT, refresh tokens, and authorization policies all work together inside a single production API is what makes every moving part click into place. Chapter 7 of the [Zero to Production course](https://aspnetcoreapi.codingdroplets.com/) covers exactly that, with a full end-to-end implementation you can run immediately.

[![ASP.NET Core Web API: Zero to Production](https://newsletter.codingdroplets.com/images/aspnet-core-api-course-banner-1.jpg align="center")](https://aspnetcoreapi.codingdroplets.com/)

* * *

## Mistake 1: Turning Off Critical Token Validation Parameters

The single most damaging configuration mistake in ASP.NET Core JWT setups is disabling validation parameters that exist to protect the API. It happens because developers disable them to unblock a failing integration test and forget to re-enable them before going to production.

The most dangerous flags to leave off are `ValidateIssuer`, `ValidateAudience`, and `ClockSkew`. When `ValidateIssuer` is `false`, any token signed with the right key — regardless of where it came from — is accepted. When `ValidateAudience` is `false`, tokens issued for a completely different service are valid against your API. A `ClockSkew` of the default five minutes means a token that "expired" five minutes ago is still accepted.

The correct approach is to validate everything: issuer, audience, signing key, lifetime, and token replay where applicable. Set `ClockSkew = TimeSpan.Zero` to enforce expiration precisely. If your integration environment is generating invalid tokens, fix the token generation — do not relax the validation.

* * *

## Mistake 2: Signing Tokens with a Weak or Hardcoded Secret Key

Symmetric JWT signing with `HS256` is common and reasonable for many APIs. The problem is what developers use as the signing key. A 16-character string, a developer's name, or the value `"your-secret-key"` from a tutorial — none of these belong anywhere near a production token.

A weak symmetric key can be brute-forced. A hardcoded key committed to source control will eventually be exposed. Either way, the attacker can sign their own tokens and impersonate any user in the system.

The fix has two parts. First, use a key that is at minimum 256 bits of cryptographically random data for `HS256`, or switch to `RS256` / `ES256` with a proper asymmetric key pair for cross-service scenarios. Second, pull the key from a secrets manager or environment variable — never from `appsettings.json` in a committed file. ASP.NET Core's [User Secrets](https://learn.microsoft.com/en-us/aspnet/core/security/app-secrets) and Azure Key Vault both integrate cleanly with the Options pattern for exactly this purpose.

* * *

## What Is the Right Way to Configure JWT in ASP.NET Core?

The right configuration validates every parameter, pulls the signing key from a secrets manager, sets `ClockSkew` to zero, and uses `RequireExpirationTime = true`. Anything weaker is a trade-off that should be documented as a deliberate decision — not an oversight.

* * *

## Mistake 3: Issuing Long-Lived Access Tokens Without Refresh Token Rotation

Access tokens are stateless by design. Once issued, there is no server-side mechanism to revoke them before expiry — short of rebuilding a revocation list, which largely defeats the purpose of stateless JWTs. This makes expiration time a critical security parameter.

Many teams set access token lifetimes to hours or days because short-lived tokens feel inconvenient. The result is that a stolen token remains valid for the entire window. If a user's device is compromised, changing their password does nothing until the token expires.

The standard pattern is to issue short-lived access tokens (15 minutes is a common starting point) paired with longer-lived refresh tokens (7 days or more) stored server-side. When the access token expires, the client exchanges the refresh token for a new pair. Refresh token rotation — issuing a new refresh token on each exchange and invalidating the previous one — adds theft detection: if an attacker uses a refresh token that has already been rotated, the server can detect reuse and revoke the entire token family.

This is one of the most impactful changes you can make to the security posture of a stateless API. The [ASP.NET Core Dependency Injection Mistakes](https://codingdroplets.com/aspnet-core-dependency-injection-mistakes-and-fixes) article is worth reviewing alongside this one — DI scope mismatches in token services are a common source of refresh token bugs.

* * *

## Mistake 4: Storing Access Tokens Insecurely on the Client

Where a token is stored on the client is just as important as how it is generated on the server. Two common choices — `localStorage` and `sessionStorage` — both expose the token to any JavaScript executing on the page. A single XSS vulnerability in a third-party script means an attacker can steal the token and make authenticated API calls from anywhere.

The more secure pattern for browser-based clients is `HttpOnly` cookies. These are inaccessible to JavaScript: the browser sends them automatically on each request but no script can read them. Combine this with the `Secure` flag (HTTPS only) and `SameSite=Strict` or `SameSite=Lax` to prevent cross-site request forgery.

For native mobile clients, use the platform's secure storage mechanisms — iOS Keychain or Android Keystore — rather than storing tokens in plain shared preferences or local storage equivalents.

* * *

## Mistake 5: Not Handling Token Expiry Gracefully on the Client

This one falls at the boundary between server behaviour and client implementation, but the server design affects it directly. When an access token expires mid-session, many APIs return a generic `401 Unauthorized` with no indication of why. The client retries with the same token, gets another 401, and the user is silently logged out.

The correct approach is to include a `WWW-Authenticate` header in the 401 response that communicates the reason: `invalid_token`, `expired_token`, or similar. This gives API clients (and the teams consuming the API) a reliable signal to trigger the refresh flow rather than treating any 401 as an authentication failure.

On the ASP.NET Core side, this means customising the `JwtBearerOptions.Events` to set the `OnChallenge` callback and write a structured response body alongside the appropriate header. Connecting this to the global error handling pipeline — covered in the [ASP.NET Core JWT Bearer 401 troubleshooting guide](https://codingdroplets.com/aspnet-core-jwt-bearer-401-unauthorized-causes-fixes) — keeps the behaviour consistent across the entire API.

* * *

## Mistake 6: Embedding Sensitive Data in the JWT Payload

JWT payloads are base64-encoded, not encrypted. Any intermediate party — a reverse proxy, a logging system, or a misbehaving library — can decode and read the claims. Developers sometimes embed email addresses, roles, internal user IDs, and even payment tier data in the payload as a convenience, reasoning that "it's in the token, not the database."

The correct approach is to treat the JWT payload as semi-public. Embed only what is necessary for the API to make an authorization decision: a subject ID, a role or scope, and standard claims like `iss`, `aud`, `exp`. Anything that should not be logged or intercepted belongs in the database, looked up by the subject ID on each authenticated request.

If you need the payload to be genuinely secret, use JWE (JSON Web Encryption) — but this is rarely necessary for most API designs and adds complexity. A cleaner payload with fewer claims is almost always the better trade-off.

* * *

## Mistake 7: Skipping Audience Validation for Multi-Service Architectures

In a single-service API, skipping audience validation feels harmless — there is only one audience. In a multi-service architecture, it creates a token substitution vulnerability: a token issued for one service is valid against any other service that trusts the same issuer.

The scenario plays out like this: Service A issues a token with no audience claim. An attacker who compromises a lower-privilege service uses a token obtained from that service to call a higher-privilege service — and it works, because both services validate the issuer and signing key but not the audience.

The fix is straightforward: include the `aud` (audience) claim in every issued token, set `ValidAudience` explicitly in `TokenValidationParameters`, and ensure `ValidateAudience = true`. In a multi-service deployment, each service should accept only tokens issued specifically for its identifier — not a generic wildcard that any service in the system satisfies.

* * *

## FAQ

### Why does my ASP.NET Core JWT authentication return 401 even with a valid token?

The most common causes are a `ClockSkew` mismatch between the token issuer and the API, a mismatched audience or issuer claim, a whitespace discrepancy in the signing key, or a token that was generated with the wrong algorithm. Enable `Microsoft.AspNetCore.Authentication.JwtBearer` debug logging and check the `AuthenticationFailed` event for the exact validation failure reason.

### What is the recommended access token lifetime for ASP.NET Core APIs?

Fifteen minutes is the industry standard starting point for short-lived access tokens. The exact value depends on your threat model — internal enterprise APIs can afford slightly longer lifetimes, while consumer-facing or high-security APIs should keep them short. Always pair short-lived access tokens with refresh tokens to avoid forcing frequent re-authentication.

### Should I use symmetric (HS256) or asymmetric (RS256) signing for JWT in ASP.NET Core?

Use `HS256` for a single-service API where only one secret needs to be shared. Use `RS256` or `ES256` when multiple services or external parties need to validate tokens — they can verify with the public key without ever seeing the private key. Asymmetric keys also make key rotation easier in distributed systems.

### How do I implement refresh token rotation in ASP.NET Core?

Store refresh tokens in the database linked to the user, a token family ID, and an expiry. On each refresh request, validate the incoming refresh token, issue a new access token and a new refresh token, and invalidate the old one. If you detect a refresh token that has already been used (possible theft), invalidate the entire family and require re-authentication.

### Is it safe to store user roles in the JWT payload?

Roles are acceptable in the JWT payload because they are relatively static, not sensitive in themselves, and needed for authorization decisions on every request. What to avoid is storing sensitive profile data, internal IDs that expose system structure, or payment/subscription details. Keep the payload minimal — a subject ID, role or scope claims, and standard registered claims.

### What is ClockSkew in JWT and why does it matter?

`ClockSkew` is a tolerance window added to the token's `exp` (expiration) and `nbf` (not before) claims to account for clock drift between servers. The ASP.NET Core default is five minutes, meaning tokens continue to be accepted for five minutes after their stated expiry. Setting `ClockSkew = TimeSpan.Zero` enforces strict expiration — recommended for any API where precise token lifetimes matter for security.

### How should I handle token revocation for stateless JWTs?

True revocation of stateless JWTs requires a revocation list — a cache or database of invalidated token IDs (the `jti` claim) that is checked on every request. This adds latency but provides immediate revocation. The common alternative is to keep access token lifetimes short enough that revocation is rarely needed, and rely on refresh token invalidation for session termination. For most APIs, short access tokens plus server-side refresh token storage is the right balance.
