ASP.NET Core gives you a lot of extension points for the OpenID Connect handler, but error handling is one area where the default behaviour is not very forgiving. If something goes wrong during the sign in flow, an incorrect client secret, a rejected consent, or a misconfigured redirect URI, the user usually ends up staring at an unhandled exception page. That is not something you want in a production application, even for an internal tool.
This article walks through a practical way to catch these OpenID Connect errors in an ASP.NET Core application that uses ASP.NET Core Identity, log the diagnostic details on the server, and redirect the user to a proper error page instead. The sample application authenticates against Microsoft Entra ID, but the approach applies to any OpenID Connect provider.
The authentication setup
The application in this example uses OpenID Connect for authenticating users and Microsoft Entra ID as the identity provider. It also uses ASP.NET Core Identity for user management. I want to flag something here: in most business applications I would avoid pulling ASP.NET Core Identity into the picture at all. User management can usually be delegated to the identity provider, and adding Identity on top of an external login just adds another layer of state to keep in sync. It is useful when you genuinely need local user profiles alongside external login, which is the case here.
The OpenID Connect client itself is registered with the standard ASP.NET Core handler rather than a vendor-specific wrapper such as Microsoft.Identity.Web. Almost every identity product, whether it is Entra ID, Duende IdentityServer, Auth0 or Keycloak, ships a client library that wraps the same underlying ASP.NET Core interfaces. Using the standard handler directly keeps things portable if you ever need to support more than one OpenID Connect provider in the same application, which Microsoft.Identity.Web does not handle well.
// Identity.External
builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = IdentityConstants.ApplicationScheme;
options.DefaultChallengeScheme = IdentityConstants.ApplicationScheme;
options.DefaultSignInScheme = IdentityConstants.ExternalScheme;
})
.AddOpenIdConnect("EntraID", "EntraID", oidcOptions =>
{
oidcOptions.SignInScheme = IdentityConstants.ExternalScheme;
oidcOptions.SignOutScheme = IdentityConstants.ApplicationScheme;
oidcOptions.RemoteSignOutPath = new PathString("/signout-callback-oidc-entra");
oidcOptions.SignedOutCallbackPath = new PathString("/signout-oidc-entra");
oidcOptions.CallbackPath = new PathString("/signin-oidc-entra");
oidcOptions.Scope.Add("user.read");
oidcOptions.Authority = $"https://login.microsoftonline.com/{builder.Configuration["AzureAd:TenantId"]}/v2.0/";
oidcOptions.ClientId = builder.Configuration["AzureAd:ClientId"];
oidcOptions.ClientSecret = builder.Configuration["AzureAd:ClientSecret"];
oidcOptions.ResponseType = OpenIdConnectResponseType.Code;
oidcOptions.UsePkce = true;
oidcOptions.MapInboundClaims = false;
oidcOptions.SaveTokens = true;
oidcOptions.TokenValidationParameters.NameClaimType = JwtRegisteredClaimNames.Name;
oidcOptions.TokenValidationParameters.RoleClaimType = "role";
})
A few things worth noting in this configuration. The DefaultSignInScheme is set to IdentityConstants.ExternalScheme rather than the application scheme directly, because the external login first needs to be linked to a local Identity user before the application cookie is issued. UsePkce is set to true, which you should always do for confidential clients too, not just public ones, since PKCE protects against authorization code interception regardless of client type. MapInboundClaims is set to false so the raw claim types coming back from Entra ID are not rewritten by the older WS-Federation claim mapping that .NET still applies by default.

The OpenID Connect events available
When you need custom logic anywhere in the OpenID Connect flow, the ASP.NET Core handler exposes a fairly complete set of events. You attach handlers to OidcOptions.Events, and each one fires at a specific point in the protocol exchange.
oidcOptions.Events = new OpenIdConnectEvents
{
// Add event handlers
OnTicketReceived = async context => {}
OnRedirectToIdentityProvider = async context => {}
OnPushAuthorization = async context => {}
OnMessageReceived = async context => {}
OnAccessDenied = async context => {}
OnAuthenticationFailed = async context => {}
OnRemoteFailure = async context => {}
// ...
};
The tricky part with error handling specifically is that there is no single event you can rely on for every provider. Some identity providers return authentication errors as a normal redirect back to your callback URL with an error query parameter, which surfaces through OnRemoteFailure. Others fail earlier, during token exchange, which surfaces through OnAuthenticationFailed instead. You often need to wire up more than one of these events to cover the failure modes your specific provider actually produces, and the only reliable way to find out which ones fire is to test against that provider directly.
Handling a remote failure
OnRemoteFailure is the event that fires when the identity provider itself reports a failure during the external authentication handshake, for example an incorrect client secret, an invalid redirect URI registered against the wrong app registration, or the user cancelling consent. Inside the handler, context.Failure gives you the exception details, and calling context.HandleResponse() stops ASP.NET Core from continuing with its default processing, which would otherwise throw an unhandled exception up the middleware pipeline.
OnRemoteFailure = async context =>
{
var logger = context.HttpContext.RequestServices.GetRequiredService<ILogger<Program>>();
logger.LogInformation("OnRemoteFailure from identity provider. Scheme: {Scheme: }", context.Scheme.Name);
if (context.Failure != null)
{
context.HandleResponse();
context.Response.Redirect($"/Error?remoteError={context.Failure.Message}");
}
await Task.CompletedTask;
}
Once this runs, the browser is redirected to a local /Error page with the failure message passed as a query parameter, instead of showing the ASP.NET Core developer exception page or a blank 500 response. Note that context.Failure.Message can sometimes contain fairly technical detail from the provider, including parts of the original error response. Do not pass this straight into the query string in a production system without checking what your provider actually puts in there. Log the full exception server side with the logger call shown above, and keep the message shown to the user generic. Passing raw provider error text into a URL is also a reflected XSS risk if that text is later rendered without encoding, so treat context.Failure.Message as untrusted input on the receiving page.
Building the error page
A plain Razor Page is enough to display the error to the user. It reads the remoteError query parameter that OnRemoteFailure set during the redirect, and also captures the current request id so support staff can correlate a user’s report with your logs.
public class ErrorModel : PageModel
{
public string? RequestId { get; set; }
public string? Error { get; set; }
public string? ErrorDescription { get; set; }
public bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
public void OnGet(string? remoteError)
{
if (remoteError != null)
{
Error = "Remote authentication error";
ErrorDescription = remoteError;
}
RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier;
}
}
The Razor markup for this page would render Error and ErrorDescription in a friendly layout, and show RequestId only when ShowRequestId is true, which is the same pattern used by the default ASP.NET Core project template’s error page. One improvement I would make for a real application: encode ErrorDescription before rendering it, and consider not showing the raw provider message to the user at all. Show a generic message on screen, log the detail server side against the RequestId, and let your support process use that id to look up what actually happened. This avoids leaking identity provider internals to end users while still giving you a way to diagnose the issue.
Production considerations
A few points to keep in mind if you are taking this pattern into a real application rather than a sample. First, every identity provider handles error reporting differently, so do not assume OnRemoteFailure alone is enough. Test the actual failure paths you care about, wrong secret, expired client, denied consent, network timeout to the provider, against your specific provider and confirm which event actually fires for each one.
Second, the content opportunity worth pursuing here is wiring these OIDC error events into Application Insights or whatever telemetry pipeline you already use. A single OnRemoteFailure event is not very informative on its own, but a spike in remote failures over a short window is usually a sign of a broken app registration, an expired secret, or an outage at the identity provider, and that is exactly the kind of thing you want an alert for rather than finding out from a support ticket.
Third, avoid using this same error page and pattern to also handle authorization failures, meaning a valid, authenticated user who simply does not have permission to view a resource. Authentication errors, where the identity provider could not establish who the user is, and authorization errors, where you know who they are but they are not allowed to do something, are different problems and mixing them into one generic error page tends to make debugging support tickets more confusing later, since the two require completely different fixes.
Leave a Reply