Skip to content

Troubleshooting

Practical problem/solution guide for Pragmatic.Identity. Each section covers a common issue, the likely causes, and the fix.


ICurrentUser.Id Is Empty Despite Valid Authentication

Section titled “ICurrentUser.Id Is Empty Despite Valid Authentication”

The user authenticates successfully (no 401), but ICurrentUser.Id returns an empty string.

  1. Check the claim type mapping. ClaimsPrincipalUserAccessor reads the user ID from the claim type specified by IdentityOptions.UserIdClaimType. The default is the long XML namespace URI http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier. If your IdP uses sub or a custom claim:

    services.AddPragmaticIdentity(opts =>
    {
    opts.UserIdClaimType = "sub";
    });
  2. Inspect the actual claims. Add a debug endpoint or log statement to see what claims the token contains:

    var claims = currentUser.Claims;
    foreach (var (key, values) in claims)
    logger.LogDebug("Claim {Key}: {Values}", key, string.Join(", ", values));
  3. Check JWT handler claim mapping. The .NET JWT handler may remap claim types. UseJwtAuthentication() configures NameClaimType = "name" and RoleClaimType = "role". If you are using a custom JWT setup, verify TokenValidationParameters.NameClaimType.

  4. Check if AddPragmaticIdentity() was called. Without it, ICurrentUser is not registered and may resolve to a default or throw.


User Is Always Anonymous (Header Auth Not Working)

Section titled “User Is Always Anonymous (Header Auth Not Working)”

Requests with X-User-Id headers result in ICurrentUser.IsAuthenticated = false.

  1. Is HeaderUserMiddleware registered in the pipeline? It must be added manually in IStartupStep.ConfigurePipeline:

    app.UseMiddleware<HeaderUserMiddleware>();
  2. Is the middleware order correct? HeaderUserMiddleware must run before UseAuthentication():

    app.UseMiddleware<HeaderUserMiddleware>(); // First
    app.UseAuthentication(); // Second
    app.UseAuthorization(); // Third
  3. Is NoOpAuthenticationHandler configured? The handler trusts identities set by earlier middleware. Without it, ASP.NET Core has no scheme to authenticate with:

    app.UseAuthentication<NoOpAuthenticationHandler>("PragmaticDefault");
  4. Is the X-User-Id header present and non-empty? HeaderUserMiddleware only creates an identity when X-User-Id exists and is not empty. Check for typos in the header name (case-sensitive).


Permissions Always Denied (403 on Everything)

Section titled “Permissions Always Denied (403 on Everything)”

Every request that requires permissions returns 403 Forbidden, even for admin users.

  1. Are role-to-permission mappings configured? If the user has roles but no direct permissions in claims, RoleExpansionProvider must map roles to permissions. Configure via UseAuthorization():

    app.UseAuthorization(authz =>
    {
    authz.MapRole<AdminRole>();
    authz.MapRole<BookingManagerRole>(r => r
    .WithPermissions("booking.reservation.create", "booking.guest.read"));
    });
  2. Are roles in the token/headers? Check that the user’s JWT or headers include role claims. For headers:

    X-User-Roles: admin,booking-manager
  3. Is AddPragmaticAuthorization() called? This registers PragmaticPermissionHandler. It is called automatically by UseAuthentication() and UseJwtAuthentication(). In a standalone setup, call it explicitly:

    services.AddPragmaticAuthorization();
  4. Is the permission name correct? Permission checks are case-insensitive but must match the configured permission string exactly (or match a wildcard pattern). A user with booking.reservation.create does not match booking.reservations.create (plural).

  5. Is the IPermissionChecker registered? The default is ClaimsPermissionChecker. Verify it is in DI:

    var checker = serviceProvider.GetService<IPermissionChecker>();
    // Should not be null
  6. Is the permission provider chain running? CachedPermissionResolver collects from all IPermissionProvider instances. If no providers are registered, the permission set is empty.


Requests with a Bearer token return 401.

  1. Is the signing key correct? The key used for generation must match the key used for validation. If they differ (e.g., different environments), the token signature is invalid.

  2. Is the token expired? Check the exp claim. The default expiration is 1 hour with 1 minute clock skew tolerance.

  3. Is the issuer correct? If JwtOptions.Issuer is set, the token’s iss claim must match exactly.

  4. Is the audience correct? If JwtOptions.Audience is set, the token’s aud claim must match exactly.

  5. Is the token well-formed? Decode the token at jwt.io and verify:

    • The header has "alg": "HS256"
    • The payload has sub, exp, and iss claims
    • The signature is valid with your key
  6. Is the Authorization header format correct? Must be Authorization: Bearer <token> (note the space after “Bearer”).

  7. Is UseJwtAuthentication() called? Verify in Program.cs that the JWT setup runs:

    app.UseJwtAuthentication(jwt =>
    {
    jwt.SigningKey = configuration["Jwt:Key"]!;
    });

ClaimsPrincipalUserAccessor Circular Dependency

Section titled “ClaimsPrincipalUserAccessor Circular Dependency”

At startup, you see an error about circular dependency involving ICurrentUser and IUserAuthorization.

CachedPermissionResolver (which implements IUserAuthorization) depends on ICurrentUser to read claims. ClaimsPrincipalUserAccessor (which implements ICurrentUser) depends on IUserAuthorization via the .Authorization property.

This is handled internally. ClaimsPrincipalUserAccessor resolves IUserAuthorization lazily via IServiceProvider instead of constructor injection:

public IUserAuthorization Authorization =>
_authorization ??= serviceProvider.GetRequiredService<IUserAuthorization>();

If you see a circular dependency error, it means something else in your DI registration is creating the cycle. Check for services that inject both ICurrentUser and IUserAuthorization in their constructors and trigger resolution of both during construction.


Permissions change in the database, but the user still sees the old permissions.

  1. Is cross-request caching enabled? Without UsePermissionCache(), permissions are resolved fresh per request. If caching is enabled, check the expiration:

    app.UseAuthorization(authz =>
    {
    authz.UsePermissionCache(TimeSpan.FromMinutes(5));
    });
  2. Is the cache being invalidated? When role/permission mappings change, invalidate the cache by user tag:

    await cache.RemoveByTagAsync($"user:{userId}");
  3. Is the cache provider registered? Cross-request caching requires ICacheStack from Pragmatic.Caching. Without it, CachedPermissionResolver falls back to per-request resolution only (no cross-request caching).

  4. Per-request caching is always on. Even without UsePermissionCache(), CachedPermissionResolver caches the resolved permission set for the lifetime of the request scope. This means permission changes take effect on the next request.


RegisterUser or LoginUser throws: “Unable to resolve service for type ‘ILocalIdentityStore’”.

  1. Did you implement ILocalIdentityStore? The Pragmatic.Identity.Local package does not include a persistence implementation. You must provide one:

    public sealed class EfLocalIdentityStore(AppDbContext db) : ILocalIdentityStore
    {
    // Implement FindByEmailAsync, FindByExternalKeyAsync, CreateAsync, UpdateAsync, EmailExistsAsync
    }
  2. Did you register it in DI?

    services.AddScoped<ILocalIdentityStore, EfLocalIdentityStore>();
  3. Is the DbContext registered? If your ILocalIdentityStore depends on a DbContext, verify it is registered and its connection string is configured.


Failed login attempts do not trigger lockout.

  1. Check LocalIdentityOptions.MaxFailedLoginAttempts. Default is 5. Verify the threshold:

    services.Configure<LocalIdentityOptions>(opts =>
    {
    opts.MaxFailedLoginAttempts = 5;
    opts.LockoutDuration = TimeSpan.FromMinutes(15);
    });
  2. Is ILocalIdentityStore.UpdateAsync persisting changes? The LoginUser action increments FailedLoginAttempts and calls UpdateAsync. If UpdateAsync does not call SaveChangesAsync, the count is lost.

  3. Is IClock registered? Lockout uses IClock.UtcNow for timestamp comparison. Without IClock, the temporal check fails. This is auto-registered when Pragmatic.Temporal is referenced.


Identity uses the PRAG1000-1099 range shared with Authorization. The following diagnostics are relevant to Identity configuration:

IDSeverityCauseFix
PRAG1000ErrorIPermission.Name is emptyEnsure the static abstract Name property returns a non-empty string literal
PRAG1001ErrorDuplicate permission name across typesEach IPermission type must have a unique Name
PRAG1002WarningPermission name does not follow conventionUse the format boundary.entity.operation (e.g., booking.reservation.create)
PRAG1003ErrorIRole.Name is emptyEnsure the static abstract Name property returns a non-empty string literal
PRAG1050ErrorDuplicate [UsePackage<T>] on moduleOnly one [UsePackage<T>] per package type per module
PRAG1051ErrorMissing [PragmaticUser] when Identity package importedAdd [PragmaticUser] to your user entity
PRAG1052ErrorIdentity property type mismatchThe owned Identity property must match the package’s identity record type
PRAG1053ErrorDuplicate package route prefixTwo packages cannot share the same route prefix

Can I use ICurrentUser outside of HTTP requests?

Section titled “Can I use ICurrentUser outside of HTTP requests?”

Yes. In background jobs, register SystemUser.Instance as the scoped ICurrentUser:

services.AddScoped<ICurrentUser>(_ => SystemUser.Instance);

In unit tests, use AnonymousUser.Instance or create a test double.

Use HeaderUserMiddleware with the X-User-Permissions header:

GET /api/orders
X-User-Id: test-user
X-User-Permissions: orders.read,orders.create

Or in integration tests with WebApplicationFactory, set headers on the HttpClient:

client.DefaultRequestHeaders.Add("X-User-Id", "test-user");
client.DefaultRequestHeaders.Add("X-User-Permissions", "orders.read,orders.create");

Can I use multiple identity providers simultaneously?

Section titled “Can I use multiple identity providers simultaneously?”

Yes. Use the UseAuthentication(auth => ...) overload to configure multiple schemes:

app.UseAuthentication(auth =>
{
auth.AddJwtBearer("Bearer", options => { /* ... */ });
auth.AddOpenIdConnect("oidc", options => { /* ... */ });
});

ASP.NET Core evaluates schemes in order. ClaimsPrincipalUserAccessor works with whatever ClaimsPrincipal the winning scheme produces.

Why does ICurrentUser.Kind only return User or Anonymous?

Section titled “Why does ICurrentUser.Kind only return User or Anonymous?”

ClaimsPrincipalUserAccessor returns PrincipalKind.User when IsAuthenticated is true, and PrincipalKind.Anonymous when false. The Service and System kinds are set by application-specific implementations. If you need to distinguish service tokens from user tokens, create a custom ICurrentUser wrapper that checks a claim (e.g., client_id or token_type) and returns PrincipalKind.Service.

Pragmatic.Identity.Local.Jwt generates tokens but does not include a refresh token mechanism. For refresh tokens, implement a refresh endpoint that validates the refresh token (stored in your database) and generates a new JWT via JwtTokenGenerator.Generate(). Consider using short-lived access tokens (15 minutes) with longer-lived refresh tokens (7 days).