Skip to main content

Local JWT Guide (Development & Testing)

Use locally-signed JWTs for development and testing without needing Auth0 or Azure AD. Perfect for unit tests, integration tests, and local development.


When to Use Local JWT

  • Local development and demos (no external IdP needed)
  • Unit/integration tests
  • CI smoke tests Not for production.

Step 1: Install Package

dotnet add package PrimusSaaS.Identity.Validator

Step 2: Generate a Signing Key

You need a secret key to sign and validate tokens. Generate one:

Option A: Random String (Simple)

// Generate a 256-bit key
var key = Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32));
Console.WriteLine(key);
// Example: "K7gNU3sdo+OL0wNhqoVWhr3g6s1xYv72ol/pe/Unols="

Option B: Using OpenSSL

openssl rand -base64 32
# Example: K7gNU3sdo+OL0wNhqoVWhr3g6s1xYv72ol/pe/Unols=

Option C: Using PowerShell

[Convert]::ToBase64String((1..32 | ForEach-Object { Get-Random -Minimum 0 -Maximum 256 }))

NOTE: Key must be at least 32 characters (256 bits) for HMAC-SHA256.


Step 3: Configure appsettings.json

{
"PrimusIdentity": {
"ValidateLifetime": true,
"ClockSkew": "00:05:00",
"Issuers": [
{
"Name": "LocalDev",
"Type": "Jwt",
"Secret": "your-256-bit-secret-key-at-least-32-chars-long!!",
"Issuer": "https://local-dev-issuer",
"Audiences": [ "local-dev-api" ]
}
],
"Diagnostics": {
"EnableDetailedErrors": true,
"IncludeTokenHintsInChallenges": true,
"IncludeDebugHeaders": true,
"LogTokenRejectionReasons": true,
"MaxRecentFailures": 50,
"AutoDetectDevelopment": true
}
}
}

Configuration Reference

PropertyRequiredDescription
NameYesFriendly name for logging
TypeYesMust be "Jwt"
SecretYesHMAC secret (min 32 chars)
IssuerYesToken issuer claim value
AudiencesYesArray of token audience values
Top-level options (PrimusIdentity):
  • ValidateLifetime (default: true)
  • ClockSkew (default: 00:05:00)

Step 4: Complete Program.cs

using PrimusSaaS.Identity.Validator;
using Microsoft.AspNetCore.Authorization;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddPrimusIdentity(opts =>
builder.Configuration.GetSection("PrimusIdentity").Bind(opts));

builder.Services.AddControllers();
builder.Services.AddAuthorization(); // Required for [Authorize] attribute

var app = builder.Build();

app.UseAuthentication();
app.UseAuthorization();

app.MapControllers();
app.Run();

Step 5: Generate Test Tokens

Option A: TestTokenBuilder (keeps secrets aligned with config)

using PrimusSaaS.Identity.Validator;

// Pull issuer/audience/secret straight from PrimusIdentity:Issuers:0
var token = TestTokenBuilder
.CreateFromConfig(builder.Configuration.GetSection("PrimusIdentity:Issuers:0"))
.WithExpiry(TimeSpan.FromHours(8)) // optional override (default is 1 hour)
.Build();

If the secret used here does not match PrimusIdentity:Issuers:<Name>:Secret, validation will fail with a 401. Keep them in sync to avoid confusing 500s during local testing.

Option B: Manual Token Generator

using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Microsoft.IdentityModel.Tokens;

public static class TestTokenGenerator
{
private const string SecretKey = "your-256-bit-secret-key-at-least-32-chars-long!!";
private const string Issuer = "https://local-dev-issuer";
private const string Audience = "local-dev-api";

public static string GenerateToken(
string userId = "test-user-123",
string email = "test@example.com",
int expiresInMinutes = 60)
{
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(SecretKey));
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);

var claims = new List<Claim>
{
new Claim(JwtRegisteredClaimNames.Sub, userId),
new Claim(JwtRegisteredClaimNames.Email, email),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
new Claim("name", "Test User")
};

var token = new JwtSecurityToken(
issuer: Issuer,
audience: Audience,
claims: claims,
expires: DateTime.UtcNow.AddMinutes(expiresInMinutes),
signingCredentials: credentials
);

return new JwtSecurityTokenHandler().WriteToken(token);
}

public static string GenerateExpiredToken()
{
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(SecretKey));
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);

var token = new JwtSecurityToken(
issuer: Issuer,
audience: Audience,
claims: new[] { new Claim("sub", "expired-user") },
expires: DateTime.UtcNow.AddMinutes(-10),
signingCredentials: credentials
);

return new JwtSecurityTokenHandler().WriteToken(token);
}
}

Option C: Add Token Endpoint to Your API

// Add this endpoint for development only!
if (app.Environment.IsDevelopment())
{
app.MapPost("/dev/token", (TokenRequest request) =>
{
var token = TestTokenGenerator.GenerateToken(
userId: request.UserId ?? "dev-user",
email: request.Email ?? "dev@example.com",
expiresInMinutes: request.ExpiresInMinutes ?? 60
);

return new {
access_token = token,
token_type = "Bearer",
expires_in = (request.ExpiresInMinutes ?? 60) * 60
};
});
}

record TokenRequest(
string UserId,
string Email,
int? ExpiresInMinutes);

Step 6: Test Your API

# Generate a token using curl (if you added the /dev/token endpoint)
TOKEN=$(curl -s -X POST http://localhost:5000/dev/token \
-H "Content-Type: application/json" \
-d '{"userId": "user-1", "email": "user@test.com"}' \
| jq -r '.access_token')

# Test protected endpoint
curl http://localhost:5000/me \
-H "Authorization: Bearer $TOKEN"

Example API (Identity Validator Example .zip)

The example zip includes a minimal API with local JWT validation.

GET /status
Headers: none
Response 200:

{
"packageInstalled": true,
"packageName": "PrimusSaaS.Identity.Validator",
"issuers": [
{ "name": "LocalDev", "type": "Jwt", "configured": true, "details": "Local JWT ready for testing" }
],
"message": "Identity Validator is installed and configured",
"nextSteps": "Test endpoints: /local (easiest), /auth0, /azuread"
}

GET /local
Headers: Authorization: Bearer <JWT>
Response 200:

{
"provider": "Local",
"userId": "user123",
"email": "test@example.com",
"validated": true,
"message": "Local JWT validated successfully"
}

Notes:

  • userId uses the sub claim.
  • email uses the email claim.

Working Example: Full Test API

using PrimusSaaS.Identity.Validator;
using Microsoft.AspNetCore.Authorization;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Microsoft.IdentityModel.Tokens;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddPrimusIdentity(opts =>
builder.Configuration.GetSection("PrimusIdentity").Bind(opts));

var app = builder.Build();

app.UseAuthentication();
app.UseAuthorization();

// Health check
app.MapGet("/", () => new { status = "healthy", auth = "Local JWT" });

// Token generator (development only!)
if (app.Environment.IsDevelopment())
{
app.MapPost("/dev/token", (TokenRequest request) =>
{
var config = builder.Configuration.GetSection("PrimusIdentity:Issuers:0");
var signingKey = config["Secret"]!;
var issuer = config["Issuer"]!;
var audience = config.GetSection("Audiences").Get<string[]>()?.FirstOrDefault() ?? "";

var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(signingKey));
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);

var claims = new List<Claim>
{
new Claim(JwtRegisteredClaimNames.Sub, request.UserId ?? "dev-user"),
new Claim(JwtRegisteredClaimNames.Email, request.Email ?? "dev@example.com"),
new Claim("name", "Dev User")
};

var token = new JwtSecurityToken(
issuer: issuer,
audience: audience,
claims: claims,
expires: DateTime.UtcNow.AddMinutes(request.ExpiresInMinutes ?? 60),
signingCredentials: credentials
);

return new {
access_token = new JwtSecurityTokenHandler().WriteToken(token),
token_type = "Bearer",
expires_in = (request.ExpiresInMinutes ?? 60) * 60
};
});
}

// Protected endpoint
app.MapGet("/me", [Authorize] (HttpContext ctx) =>
{
return new {
userId = ctx.User.FindFirst("sub")?.Value,
email = ctx.User.FindFirst("email")?.Value,
name = ctx.User.FindFirst("name")?.Value
};
});

app.Run();

record TokenRequest(string? UserId, string? Email, int? ExpiresInMinutes);

appsettings.Development.json

{
"PrimusIdentity": {
"Issuers": [
{
"Name": "LocalDev",
"Type": "Jwt",
"Secret": "your-256-bit-secret-key-at-least-32-chars-long!!",
"Issuer": "https://local-dev-issuer",
"Audiences": [ "local-dev-api" ]
}
],
"Diagnostics": {
"EnableDetailedErrors": true
}
}
}

Using Local JWT in Unit Tests

using Xunit;
using Microsoft.AspNetCore.Mvc.Testing;
using System.Net.Http.Headers;

public class ApiTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
private readonly HttpClient _client;

public ApiTests(WebApplicationFactory<Program> factory)
{
_factory = factory;
_client = factory.CreateClient();
}

[Fact]
public async Task ProtectedEndpoint_WithValidToken_ReturnsOk()
{
var token = TestTokenGenerator.GenerateToken(
userId: "test-user",
email: "test@example.com"
);
_client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", token);

var response = await _client.GetAsync("/me");

Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}

[Fact]
public async Task ProtectedEndpoint_WithExpiredToken_Returns401()
{
var token = TestTokenGenerator.GenerateExpiredToken();
_client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", token);

var response = await _client.GetAsync("/me");

Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
}

Troubleshooting

Error: "IDX10603: Key size must be greater than 256 bits"

Cause: Signing key too short.

Solution: Use a key with at least 32 characters:

"Secret": "at-least-32-characters-long-secret-key-here!!"

Error: "IDX10223: Lifetime validation failed"

Cause: Token expired.

Solution: Generate a new token or disable lifetime validation for testing:

{
"PrimusIdentity": {
"ValidateLifetime": false
}
}

Error: "IDX10208: Unable to validate audience"

Cause: Audience mismatch.

Solution: Ensure token aud claim matches configured Audience.

Debug: Decode token

var handler = new JwtSecurityTokenHandler();
var jwt = handler.ReadJwtToken(token);
Console.WriteLine($"Issuer: {jwt.Issuer}");
Console.WriteLine($"Audience: {string.Join(", ", jwt.Audiences)}");
Console.WriteLine($"Expires: {jwt.ValidTo}");
foreach (var claim in jwt.Claims)
{
Console.WriteLine($" {claim.Type}: {claim.Value}");
}

Security Reminder

WARNING: Local JWT is for development/testing only.

  • Never use the same signing key in production
  • Never commit signing keys to source control
  • Use environment variables or user secrets for keys:
    dotnet user-secrets set "PrimusIdentity:Issuers:0:Secret" "your-secret"

Next Steps

Want to...See Guide
Use Auth0 in productionAuth0 Guide ->
Use Azure AD in productionAzure AD Guide ->
Combine with external providersMulti-Issuer Setup ->
Revisit the basicsIdentity Quick Start ->