# Customizing 401 Responses with JwtBearerEvents in ASP.NET Core

Every error your ASP.NET Core API returns is a documented, structured Problem Details payload. Except one. When a bearer token is missing, expired, or malformed, the client gets a 401 with an empty body and no explanation, because that response never passes through your exception handler at all. Using `JwtBearerEvents` for a custom 401 response in ASP.NET Core is how you close that gap, and in production I've watched this single inconsistency generate more support tickets than any genuine auth bug, because a mobile client cannot tell "your token expired, refresh it" apart from "you were never authenticated, log in again".

The fix is small. Getting it right without leaking token internals or breaking the `WWW-Authenticate` contract takes a little more care, and that is what this walkthrough covers. If you want the whole auth surface assembled - challenge handling, refresh flow, and the tests that pin the behaviour - the complete annotated version lives on [Patreon](https://www.patreon.com/CodingDroplets).

Shaping the 401 is the last mile of a token pipeline, and it only makes sense once the validation parameters underneath it are right. [Chapter 7 of the Zero to Production course](https://aspnetcoreapi.codingdroplets.com/) builds JWT authentication with refresh tokens end to end, including the `ClockSkew` setting that decides when a token is considered expired in the first place.

[![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/)

## The Problem: A 401 That Tells the Client Nothing

Add `AddJwtBearer()`, decorate a controller with `[Authorize]`, and send a request without a token. You get:

```plaintext
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer
Content-Length: 0
```

No body. Now send an expired token instead. You get the same 401, with a slightly different `WWW-Authenticate` header that most HTTP clients never surface to application code. From the caller's perspective, three completely different situations are indistinguishable:

*   No credentials were sent at all
    
*   Credentials were sent but the token has expired and should be refreshed
    
*   Credentials were sent but the token is invalid and refreshing will not help
    

The consequences are practical. Clients implement refresh-on-any-401 and end up in refresh loops. Front-end code logs users out when it should have silently renewed. Your API returns Problem Details for every failure except the most common one.

## Why It Happens

The authentication middleware writes the challenge response directly. It does not throw, so `UseExceptionHandler` never sees it, and by the time `IProblemDetailsService` would normally get involved the response has already been decided. This is not a bug: a challenge is a protocol-level response defined by [RFC 6750](https://datatracker.ietf.org/doc/html/rfc6750), not an application error.

[`JwtBearerEvents`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.authentication.jwtbearer.jwtbearerevents) is the supported extension point. Four callbacks matter here, and they fire in this order:

| Event | When it fires | Typical use |
| --- | --- | --- |
| `OnMessageReceived` | Every request, before validation | Read the token from a cookie or query string |
| `OnAuthenticationFailed` | Validation failed | Inspect the exception, add a hint header |
| `OnTokenValidated` | Validation succeeded | Enrich the principal, check revocation |
| `OnChallenge` | Just before the 401 is written | Replace the response body |

`OnChallenge` is last, which is exactly why it is the right place to write a body. Anything you write earlier risks being overwritten or, worse, triggering the [response has already started error](https://codingdroplets.com/response-already-started-aspnet-core) when the default handler runs afterwards.

## How to Diagnose It

Before writing any code, confirm what the framework is already telling you. Two checks take a minute each:

1.  **Look at the** `WWW-Authenticate` **header on a failing request.** With `IncludeErrorDetails` enabled, an expired token produces `error="invalid_token"` and a description naming the expiry. If that header is absent, the request never reached the JWT handler and your problem is routing or middleware order, not the challenge.
    
2.  **Log the failure exception.** In `OnAuthenticationFailed`, `context.Exception` tells you precisely what failed - `SecurityTokenExpiredException`, `SecurityTokenInvalidAudienceException`, `SecurityTokenSignatureKeyNotFoundException`. If you are seeing signature key errors, the fix is in your validation configuration, and no amount of response shaping will help. Our guide to [401 Unauthorized causes and fixes with JWT bearer](https://codingdroplets.com/aspnet-core-jwt-bearer-401-unauthorized-causes-fixes) covers that diagnostic path in depth.
    

## The Fix

Two events, one shared response shape. Start by recording *why* authentication failed, then use that when writing the challenge.

```csharp
options.Events = new JwtBearerEvents
{
    OnAuthenticationFailed = context =>
    {
        if (context.Exception is SecurityTokenExpiredException)
            context.HttpContext.Items["auth_error"] = "token_expired";
        return Task.CompletedTask;
    },

    OnChallenge = async context =>
    {
        context.HandleResponse();          // suppress the default empty 401
        context.Response.StatusCode = StatusCodes.Status401Unauthorized;

        var problem = new ProblemDetails
        {
            Status = StatusCodes.Status401Unauthorized,
            Title  = "Unauthorized",
            Type   = "https://tools.ietf.org/html/rfc7235#section-3.1",
            Detail = context.HttpContext.Items["auth_error"] as string switch
            {
                "token_expired" => "The access token has expired. Refresh it and retry.",
                _               => "A valid bearer token is required for this resource."
            }
        };

        await context.HttpContext.Response.WriteAsJsonAsync(problem);
    }
};
```

Three details that are easy to miss:

*   `context.HandleResponse()` **is mandatory.** Without it the default handler still runs and appends its own response. This is the single most common mistake with `OnChallenge`.
    
*   **Set the status code explicitly.** `HandleResponse()` short-circuits the default behaviour, including the status code it would have set.
    
*   **Do not remove** `WWW-Authenticate`**.** It is required by the HTTP specification for a 401 and some clients depend on it. Adding a body does not mean discarding the header.
    

For consistency with the rest of your API, resolve `IProblemDetailsService` instead of writing the object directly, so your custom problem-details customisations apply here too. Our [API response standardization guide](https://codingdroplets.com/aspnet-core-api-response-standardization-enterprise-decision-guide) covers why a single response shape is worth this effort.

## What About 403?

A 403 is a different failure and needs a different callback. `OnForbidden` fires when the caller *is* authenticated but the authorization policy said no. Returning "please log in" there sends clients into a pointless re-authentication loop.

Keep the distinction sharp: 401 means "I do not know who you are"; 403 means "I know who you are and the answer is still no". The detail message for a 403 should never suggest refreshing a token.

## How Much Detail Is Safe to Return?

Return the failure *category*, never the diagnostic internals.

Safe to expose: the token expired, the token is missing, the token format is invalid. These tell an honest client what to do next, and RFC 6750 already puts equivalent information in the `WWW-Authenticate` header, so you are not leaking anything new.

Never expose: the expected issuer or audience values, key identifiers, the raw exception message, or stack traces. An attacker probing your API should not be able to enumerate your validation configuration from error responses. This is the same discipline as never surfacing `exception.Message` on a 500.

The practical rule we apply: map exception types to a small fixed set of client-facing codes, and log the full exception server-side with a correlation id the caller can quote to support.

## Preventing the Regression

*   **Write integration tests for the failure paths.** Assert the status code, the presence of `WWW-Authenticate`, and the exact body shape for missing, expired, and malformed tokens. These tests are cheap and they catch the day someone "cleans up" the events block.
    
*   **Set** `ClockSkew` **deliberately.** The default five-minute tolerance means a token can be accepted for minutes after it expires, which makes expiry behaviour hard to test and reason about. Setting it to zero makes expiry mean expiry.
    
*   **Keep the shape in one place.** If you have several authentication schemes, factor the challenge writer into a shared helper rather than copying the events block per scheme.
    
*   **Document the codes.** Whatever categories you return, put them in your OpenAPI description. A client team cannot handle a code they have to discover by experiment. While you are there, our list of [common JWT authentication mistakes in ASP.NET Core](https://codingdroplets.com/jwt-authentication-mistakes-aspnet-core) is worth a pass over the surrounding configuration.
    

## FAQ

### Why is my OnChallenge handler not returning my custom response?

Almost always because `context.HandleResponse()` was not called. Without it, ASP.NET Core continues with the default challenge after your handler runs, and the default wins. The second most common cause is writing the body before setting the status code, which leaves you with a 200 containing an error payload.

### How do I tell the client that a JWT expired rather than that it was missing?

Capture the failure in `OnAuthenticationFailed`, where `context.Exception` is a `SecurityTokenExpiredException` for an expired token, and stash a category in `HttpContext.Items`. Read it back in `OnChallenge` and map it to a stable, client-facing code. Do not parse the exception message, and do not return it verbatim.

### Can I return Problem Details from JwtBearerEvents in ASP.NET Core?

Yes. Resolve `IProblemDetailsService` from `context.HttpContext.RequestServices` inside `OnChallenge` and write through it, so any global problem-details customisation you have registered applies to authentication failures too. Writing the object directly with `WriteAsJsonAsync` also works, but then your 401 diverges from every other error your API returns.

### What is the difference between OnChallenge and OnForbidden?

`OnChallenge` handles 401 responses, meaning authentication did not succeed. `OnForbidden` handles 403 responses, meaning authentication succeeded but an authorization policy rejected the request. Conflating them causes clients to attempt a token refresh in response to a permissions problem, which will never resolve.

### Is it safe to include the token expiry time in a 401 response?

Stating that the token has expired is safe and useful, and the bearer token specification already allows an equivalent description in the `WWW-Authenticate` header. Returning exact timestamps, issuer values, audience values, or key identifiers is not, because those help an attacker map your validation configuration. Log the specifics; return the category.

### Does customizing the 401 response affect Swagger or OpenAPI?

Not automatically. The events block changes runtime behaviour only, so your generated document will keep describing a bare 401 unless you add response metadata yourself. Declare the 401 and 403 shapes on your endpoints so the generated contract matches what clients actually receive.

* * *

## About the Author

I'm Celin Daniel, Co-founder of [Coding Droplets](https://codingdroplets.com/). I've been building .NET and ASP.NET Core systems in production for 13+ years - APIs, distributed backends, enterprise platforms. Everything I write here comes from real shipping experience: patterns that held up, trade-offs that bit us, and lessons learned the hard way.

*   GitHub: [codingdroplets](http://github.com/codingdroplets/)
    
*   YouTube: [Coding Droplets](https://www.youtube.com/@CodingDroplets)
    
*   Website: [codingdroplets.com](https://codingdroplets.com/)
