Skip to main content

How Auth Works

This page explains the technical implementation of authentication in the API — how multiple JWT schemes are registered, how the routing policy works, and what each component is responsible for.


Overview

The API uses ASP.NET Core's built-in JWT Bearer authentication with two named schemes and a policy scheme that acts as a router between them.

AddAuthentication()

├── AddPolicyScheme("MultiScheme") ← default scheme, acts as router
├── AddJwtBearer("Auth0") ← validates Auth0 tokens
└── AddJwtBearer("UserJwts") ← validates local dev tokens (Dev only)

The default scheme is always MultiScheme. ASP.NET Core hits this first on every authenticated request, it inspects the token, then forwards to the appropriate named handler. No token is validated twice.


Policy Scheme (Router)

The policy scheme doesn't validate anything itself — it only decides which real handler to forward to. It does this by peeking at the JWT's iss claim without validating the signature:

.AddPolicyScheme("MultiScheme", "Auth0 or UserJwts", options =>
{
options.ForwardDefaultSelector = context =>
{
var authHeader = context.Request.Headers.Authorization.FirstOrDefault();
if (builder.Environment.IsDevelopment() && authHeader?.StartsWith("Bearer ") == true)
{
var token = authHeader["Bearer ".Length..].Trim();
var jwtHandler = new JwtSecurityTokenHandler();

if (jwtHandler.CanReadToken(token))
{
var issuer = jwtHandler.ReadJwtToken(token).Issuer;
if (issuer.Equals("dotnet-user-jwts", StringComparison.OrdinalIgnoreCase))
return AuthSchemes.UserJwts;
}
}
return AuthSchemes.Auth0;
};
})
Security note

Reading the issuer without validating the signature is safe here because this step only determines which handler to use — it doesn't grant any access. The selected handler always performs full cryptographic validation before the request proceeds.


Auth0 Scheme

The Auth0 handler uses asymmetric (RS256) validation. Auth0 signs tokens with a private key that only Auth0 holds. The API validates tokens using Auth0's public keys, fetched from the JWKS endpoint.

.AddJwtBearer(AuthSchemes.Auth0, options =>
{
options.Authority = $"https://{config["Auth0:Domain"]}/";
options.Audience = config["Auth0:Audience"];
})

Setting Authority causes the handler to automatically:

  1. Fetch the OpenID Connect discovery document from /.well-known/openid-configuration
  2. Extract the JWKS URI from that document
  3. Fetch and cache the public signing keys
  4. Use those keys to validate the token signature

The keys are refreshed automatically if a token references a key ID (kid) not found in the current cache — this handles Auth0 key rotation transparently.

What gets validated

ParameterValue
SignatureRS256 using Auth0 public JWKS
Issuerhttps://your-tenant.auth0.com/
AudienceYour API identifier from Auth0 dashboard
ExpiryToken exp claim must be in the future

UserJwts Scheme (Development only)

The UserJwts handler uses symmetric (HS256) validation. A single secret key is used to both sign and verify tokens. This key lives in your local user secrets and is never committed to source control.

if (builder.Environment.IsDevelopment())
{
authBuilder.AddJwtBearer(AuthSchemes.UserJwts, options =>
{
var config = builder.Configuration.GetSection("Authentication:Schemes:UserJwts");

config.Bind(options);

var signingKeys = config.GetSection("SigningKeys").GetChildren();

options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKeys = signingKeys.Select(key =>
new SymmetricSecurityKey(
Convert.FromBase64String(key["Value"]!)
)
).ToList<SecurityKey>(),

ValidateIssuer = true,
ValidIssuers = new[] { config["ValidIssuer"] ?? "dotnet-user-jwts" },

ValidateAudience = true,
ValidAudiences = config.GetSection("ValidAudiences").Get<string[]>(),

ValidateLifetime = true
};
});
}

The signing key is stored in user secrets under:

Authentication:Schemes:UserJwts:SigningKeys:0:Value

dotnet user-jwts writes this key when you run dotnet user-jwts create --scheme UserJwts. Because it's in user secrets, it is:

  • Per-developer — each developer has their own key
  • Not in source control — user secrets are stored in your OS profile directory
  • Not in appsettings.json — safe to commit the rest of your config

Why the manual key loading is needed

AddJwtBearer with a custom scheme name doesn't automatically bind signing keys from configuration — it only binds scalar options like Authority and Audience. The SigningKeys array contains base64-encoded key material that must be explicitly decoded into SymmetricSecurityKey objects and assigned to TokenValidationParameters.IssuerSigningKeys.


Environment Guard

The UserJwts scheme is only registered when the application is running in the Development environment:

if (builder.Environment.IsDevelopment())
{
authBuilder.AddJwtBearer(AuthSchemes.UserJwts);
}

In any other environment (Staging, Production), the scheme does not exist. If a token with iss: dotnet-user-jwts arrives in production:

  1. The policy scheme's IsDevelopment() check is false
  2. It falls through to the Auth0 handler
  3. Auth0 rejects it — wrong issuer, wrong signature algorithm
  4. The request gets a 401

There is no configuration flag to enable dev tokens in production — the guard is structural.


Scheme Constants

All scheme names are centralised in a static class to avoid magic strings:

public static class AuthSchemes
{
public const string Auth0 = "Auth0";
public const string UserJwts = "UserJwts";
public const string Default = "MultiScheme";
}

User Secrets Storage Location

User secrets are stored outside the project directory in your OS profile:

OSPath
Windows%APPDATA%\Microsoft\UserSecrets\<UserSecretsId>\secrets.json
macOS / Linux~/.microsoft/usersecrets/<UserSecretsId>/secrets.json

The <UserSecretsId> matches the GUID in your .csproj. This is why running dotnet user-jwts create from the wrong directory writes the key to the wrong project.


Adding Claims to Local Tokens

You can attach arbitrary claims to a local token for testing role-based or policy-based authorization:

dotnet user-jwts create \
--name "dev-user" \
--scheme UserJwts \
--claim "role=admin" \
--claim "email=dev@example.com" \
--claim "department=engineering"

These claims will be available on HttpContext.User exactly as they would be from a real Auth0 token, so you can test authorization policies without mocking anything.