Skip to main content

Command Palette

Search for a command to run...

Customizing 401 Responses with JwtBearerEvents in ASP.NET Core

Updated
9 min readView as Markdown
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.

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 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

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:

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, not an application error.

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 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 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.

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 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 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. 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.

More from this blog

C

Coding Droplets

308 posts

Coding Droplets is your go-to resource for .NET and ASP.NET Core development. Whether you're just starting out or building production systems, you'll find practical guides, real-world patterns, and clear explanations that actually make sense.

From beginner-friendly tutorials to advanced architecture decisions. We publish fresh .NET content every day to help you grow at every stage of your career.