Issuing Tokens for Development and Tests
Get valid JWTs locally with dotnet user-jwts and mint test tokens in integration tests, without turning your API into a token server.
dotnet user-jwts create --name ana --role admin --scope orders.write
var token = new JsonWebTokenHandler().CreateToken(new SecurityTokenDescriptor { ... });An API that validates tokens from an identity provider still needs tokens on a developer laptop and in CI, where the provider may not be reachable and nobody wants a real user's password in a test. There are two tools for this, and one rule about production.
dotnet user-jwts for local development
dotnet user-jwts create generates a signing key, stores it in the project's user secrets under Authentication:Schemes:Bearer:SigningKeys, writes ValidIssuer (dotnet-user-jwts) and ValidAudiences (the URLs from launchSettings.json) into appsettings.Development.json, and prints a token. Options shape the token: --name, --role, --scope, --claim tenant=acme, --audience, --valid-for 1h. It works because AddJwtBearer() binds that configuration section, including the signing keys. Production configuration supplies Authority instead, and the development key exists only in the developer's user secrets file.
Two traps: if code sets Authority unconditionally, the handler ignores the local keys and tries to download metadata, so keep Authority in configuration rather than in code; and a configure lambda that replaces TokenValidationParameters wholesale discards the bound keys and audiences.
Minting tokens in integration tests
In WebApplicationFactory tests, create a throwaway RSA test key (its algorithm must be one of the API's ValidAlgorithms, so RS256 when production pins RS256), sign tokens with JsonWebTokenHandler.CreateToken, and give the API that key through Configure<JwtBearerOptions> in ConfigureTestServices. Setting options.Configuration to an OpenIdConnectConfiguration with the test issuer and key stops the handler from fetching metadata, while the app's real audience, lifetime and claim settings still run. That is the point: tests exercise the production validation rules, so a test can prove that an expired token or a token for billing-api gets 401.
| Approach | Tests the real validation | Use for |
|---|---|---|
| Signed test tokens (this lesson) | Yes: signature, audience, expiry, claim mapping | Auth-focused tests, 401 cases |
| Test authentication handler | No, it skips tokens entirely | Feature tests that only need a user with claims |
| Real provider in CI | Yes, plus network and config | A few end-to-end smoke tests |
Why the API should not mint its own tokens
A POST /token that checks a password and signs a JWT looks like twenty lines. It makes the API an authorization server: key storage and rotation, refresh tokens and their revocation, lockout, MFA, password reset, audit, and a signing key that, if it leaks from any replica, lets an attacker mint tokens for every user. Use the identity provider (Entra ID, Auth0, Keycloak, Duende IdentityServer), or ASP.NET Core Identity's endpoints for a single first-party app. Keep token creation in test projects and local tooling, where the key is throwaway.
Example
using System.Net;
using System.Net.Http.Headers;
using System.Security.Claims;
using System.Security.Cryptography;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.IdentityModel.Tokens;
using Xunit;
public static class TestTokens
{
public const string Issuer = "https://login.shop.test/realms/shop";
// Same algorithm family as production (RS256), so the API's ValidAlgorithms pin still applies.
public static readonly RsaSecurityKey Key = new(RSA.Create(2048)) { KeyId = "test-key" };
public static string Create(string customerId, string scope, string audience = "orders-api", TimeSpan? lifetime = null)
{
var now = DateTime.UtcNow;
var expires = now + (lifetime ?? TimeSpan.FromMinutes(10));
return new JsonWebTokenHandler().CreateToken(new SecurityTokenDescriptor
{
Issuer = Issuer,
Audience = audience,
IssuedAt = expires.AddMinutes(-30),
NotBefore = expires.AddMinutes(-30),
Expires = expires,
Subject = new ClaimsIdentity([new Claim("sub", customerId), new Claim("scope", scope)]),
SigningCredentials = new SigningCredentials(Key, SecurityAlgorithms.RsaSha256),
});
}
}
public sealed class OrdersApiFactory : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureTestServices(services =>
{
// Configure runs after the app's own AddJwtBearer lambda and before post-configuration,
// so the handler uses this static configuration instead of downloading metadata.
services.Configure<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme, options =>
{
options.Configuration = new OpenIdConnectConfiguration { Issuer = TestTokens.Issuer };
options.Configuration.SigningKeys.Add(TestTokens.Key);
});
});
}
}
public class OrderAuthenticationTests(OrdersApiFactory factory) : IClassFixture<OrdersApiFactory>
{
[Fact]
public async Task No_token_is_401()
{
var client = factory.CreateClient();
var response = await client.GetAsync("/orders/mine", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task Valid_token_with_read_scope_is_200()
{
var response = await GetWith(TestTokens.Create("cust-42", "orders.read"));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task Token_for_another_api_is_401()
{
var response = await GetWith(TestTokens.Create("cust-42", "orders.read", audience: "billing-api"));
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task Token_expired_beyond_clock_skew_is_401()
{
var response = await GetWith(TestTokens.Create("cust-42", "orders.read", lifetime: TimeSpan.FromMinutes(-10)));
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
private async Task<HttpResponseMessage> GetWith(string token)
{
var client = factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
return await client.GetAsync("/orders/mine", TestContext.Current.CancellationToken);
}
}
// Stand-in so this file compiles on its own. Delete it in a real test project: in .NET 10 the API's
// generated Program is already public, and a Program declared here would shadow it (CS0436).
public partial class Program;When to use it
- Call a locally running orders API with a token that has role support and scope orders.write, without signing in anywhere.
- Prove in CI that a token issued for billing-api or expired ten minutes ago gets 401 from orders-api.
- Stop a team from adding a POST /token endpoint that signs JWTs with a key stored in appsettings.json.
More examples
dotnet user-jwts on a developer machine
The key lives in the user secrets file under the developer's profile, not in the repository, and <code>appsettings.Development.json</code> only gains the issuer and audiences. Use <code>--scope</code> and <code>--role</code> to reproduce exactly the claims a policy expects, and create a deliberately wrong token with <code>--audience</code> to see the 401 path locally. Resetting the key invalidates every token you handed to teammates or saved in <code>.http</code> files.
# Run in the API project folder; creates UserSecretsId and a signing key on first use.
dotnet user-jwts create --name [email protected] --scope orders.read --scope orders.write \
--role support --claim tenant=acme --valid-for 8h
# List and inspect what was issued (the token itself is printed by create and by print).
dotnet user-jwts list
dotnet user-jwts print <token-id> --show-all
# A token for a different audience, to check the API rejects it.
dotnet user-jwts create --name [email protected] --audience billing-api
# Call the API with the printed token.
curl -i https://localhost:7212/orders/mine -H "Authorization: Bearer <paste-token>"
# Start over: new key, all earlier tokens stop validating.
dotnet user-jwts key --reset --forceWhat user-jwts adds to appsettings.Development.json
The signing key is not here; it sits in user secrets as <code>Authentication:Schemes:Bearer:SigningKeys</code>. Because this file is Development-only, production reads <code>Authority</code> and the real <code>ValidAudiences</code> from <code>appsettings.json</code> or environment variables, and the <code>dotnet-user-jwts</code> issuer is never trusted there. Check that no shared environment runs with <code>ASPNETCORE_ENVIRONMENT=Development</code>, or this configuration follows it.
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"Authentication": {
"Schemes": {
"Bearer": {
"ValidAudiences": [
"http://localhost:5212",
"https://localhost:7212"
],
"ValidIssuer": "dotnet-user-jwts"
}
}
}
}Program.cs that works with both
Leaving everything to configuration is what lets <code>dotnet user-jwts</code> work locally and the real provider work in production with identical code. Note that <code>RequireClaim("scope", "orders.read")</code> matches a claim whose whole value is <code>orders.read</code>: user-jwts emits one <code>scope</code> claim per <code>--scope</code>, while many providers send one space-separated claim, so production policies usually split the value as shown in the JWT bearer lesson.
using System.Security.Claims;
var builder = WebApplication.CreateBuilder(args);
// No lambda: Authority, ValidAudiences, ValidIssuer and SigningKeys all come from
// Authentication:Schemes:Bearer, which differs per environment.
builder.Services.AddAuthentication().AddJwtBearer();
builder.Services.AddAuthorizationBuilder()
.AddPolicy("orders:read", policy => policy.RequireClaim("scope", "orders.read"));
var app = builder.Build();
app.MapGet("/orders/mine", (ClaimsPrincipal user) =>
TypedResults.Ok(new { customer = user.Identity?.Name, orders = new[] { 1001, 1002 } }))
.RequireAuthorization("orders:read");
app.Run();
Discussion