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, 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 covers exactly that, with a full end-to-end implementation you can run immediately.
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 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 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 β 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.





