Edge Cases & Troubleshooting
This guide covers every non-happy-path scenario in the Identity Broker, organised by feature area. For each scenario you will find: what happens, why it happens, how to handle it in your code, and what to look for in the audit log.
Find the scenario that matches your situation, read the "What happens" note to confirm the behaviour, then follow the "How to handle it" advice to resolve it in your application.
1. Local Login
1.1 User does not exist
What happens: 401 Unauthorized. The response body is identical to the "wrong password" response.
Why: Returning different messages for "email not found" vs. "wrong password" allows attackers to enumerate valid email addresses.
How to handle it: Display a generic message such as "Incorrect email or password" — never tell the user which part was wrong.
Audit event: LoginFailed with details "email_not_found".
1.2 Wrong password
What happens: 401 Unauthorized — same response as 1.1.
Audit event: LoginFailed with details "invalid_credentials".
1.3 Valid credentials but MFA not enrolled (Mfa.Enabled = true, RequireAllUsers = false)
What happens: Login succeeds normally and a session cookie is set. MFA is opt-in by default.
1.4 Valid credentials, MFA enrolled
What happens: 202 Accepted — no session cookie yet. The response body contains a mfaPendingToken. Send POST /api/auth/mfa/verify next.
1.5 Login blocked by rate limit
What happens: 429 Too Many Requests. The Retry-After header tells the frontend how many seconds to wait.
How to handle it: Disable the submit button and show a countdown timer. Do not retry silently.
Audit event: RateLimitExceeded.
1.6 Missing CSRF token on login request
What happens: 400 Bad Request. No credential check is performed.
How to handle it: Ensure GET /api/auth/providers was called before the login form was shown. Read the XSRF-TOKEN cookie and include its value in the X-Primus-CSRF request header on every POST.
1.7 Case sensitivity in email
What happens: Credential validation is delegated to your IPrimusAuthCredentialValidator. Whether Alice@EXAMPLE.COM and alice@example.com are treated as the same account depends on your implementation.
Recommendation: Normalise email addresses to lowercase before storing and before looking them up.
2. MFA: TOTP
2.1 Wrong TOTP code
What happens: POST /mfa/verify returns 401 Unauthorized. The pendingToken remains valid until it expires — the user can try again.
Audit event: MfaFailed.
2.2 Expired pending token
What happens: POST /mfa/verify returns 422 Unprocessable Entity. The pending token TTL (default: 5 minutes) has elapsed.
How to handle it: The user must return to the login form and submit their credentials again.
2.3 Pending token reuse within expiry window
What happens: POST /mfa/verify returns 422 Unprocessable Entity on the second or any subsequent call with the same mfaPendingToken. Pending tokens are single-use — the SDK marks each token as consumed via IPrimusConsumedPendingTokenStore on the first successful call. Replaying the same token is rejected immediately, even if the token has not yet expired.
Implication for your app: No action required — single-use enforcement is built-in. If you supply a custom IPrimusConsumedPendingTokenStore backed by a distributed cache, the protection extends across multiple application instances.
2.4 TOTP clock drift
What happens: A valid TOTP code is rejected with 401. The Broker accepts codes within a ±30 s tolerance. Beyond ±30 s, codes fail.
How to fix: Ensure NTP synchronisation on the user's device. Enable "automatic time" on mobile devices.
2.5 User lost their authenticator app
Recommended recovery flow:
- On the MFA step, offer a "Use a backup code instead" option.
- User enters a backup code in the
codefield ofPOST /mfa/verify. - If all backup codes are exhausted, an admin must call
IPrimusMfaStore.DisableMfaAsync()for the user account.
2.6 Enrolment token expired
What happens: POST /mfa/enroll/confirm returns 422. The user must restart with a fresh POST /mfa/enroll.
2.7 Wrong code during enrolment confirmation
What happens: POST /mfa/enroll/confirm returns 422. The user must restart with POST /mfa/enroll to get a fresh secret and QR code.
3. MFA: Global Enforcement (RequireAllUsers)
3.1 Unenrolled user logs in when RequireAllUsers = true
What happens: 403 Forbidden.
{
"mfaSetupRequired": true,
"message": "MFA enrollment is mandatory for this application. Please enroll before continuing."
}
Audit event: MfaEnforcementBlocked.
How to handle it: Detect 403 + mfaSetupRequired: true. Redirect to your MFA enrolment page. The user must complete /mfa/enroll + /mfa/enroll/confirm before they can log in.
3.2 RequireAllUsers = true but Mfa.Enabled = false
What happens: RequireAllUsers is silently ignored. Both must be true for enforcement to apply.
How to check: Verify "Mfa": { "Enabled": true, "RequireAllUsers": true } in your configuration.
3.3 Trying to disable MFA when RequireAllUsers = true
What happens: POST /mfa/disable returns 403 Forbidden. A user cannot disable MFA in an app that mandates it.
How to handle it: Do not show the "Disable MFA" UI option when RequireAllUsers = true. If a user must reset MFA (e.g., lost authenticator), an admin must call IPrimusMfaStore.DisableMfaAsync() directly.
3.4 OIDC SSO + RequireAllUsers = true
What happens: After a successful OIDC callback, if the mapped user is not enrolled in MFA, context.Fail("mfa_setup_required") is called and no session is issued.
How to handle it: Configure a dedicated OnRemoteFailure handler to detect mfa_setup_required and redirect to your MFA enrolment page.
4. MFA: Backup Codes
4.1 All backup codes exhausted
What happens: POST /mfa/verify with a backup code returns 401. An admin must call DisableMfaAsync(userId) to reset the user's MFA state.
4.2 Backup code replay attack
What happens: A used backup code returns 401. IPrimusMfaStore.ConsumeBackupCodeAsync() marks codes as consumed — they cannot be reused.
4.3 Backup codes not stored by the user
Recommendation: On the enrolment confirmation screen, force the user to acknowledge that they have saved their backup codes. Consider offering a "download as text file" option.
5. OIDC SSO
5.1 OIDC configuration missing or wrong
| Symptom | Likely Cause | Fix |
|---|---|---|
"Invalid client_id" from provider | Wrong ClientId in config | Re-copy from your app registration |
"Redirect URI mismatch" from provider | Callback URL not registered in IdP | Register the Broker's callback path in your IdP (e.g. https://yourapp.com/api/auth/azure/callback for Azure AD, https://yourapp.com/api/auth/auth0/callback for Auth0) |
"Tenant not found" (Azure) | Wrong TenantId | Verify the tenant GUID in Azure Portal |
| Redirect loop after callback | Callback path duplicated | Ensure CallbackPath matches the ASP.NET OIDC middleware default |
5.2 OIDC user not mapped to a local account
What happens: If FindByEmailAsync() or AutoProvisionUserAsync() throws, the callback fails and the user is not logged in.
How to handle it: Ensure your implementation handles both returning an existing user and creating a new user on first login.
5.3 Open redirect via returnUrl
What happens: An absolute URL pointing to an external domain is rejected — the user is redirected to the default post-login URL. GET /api/auth/azure?returnUrl=https://evil.com cannot succeed.
5.4 SAML IdP not configured
What happens: Both GET /api/auth/saml/{scheme} (SP metadata) and GET /api/auth/saml/{scheme}/SignIn (SSO initiation) return 404 if no SAML IdP with that scheme name has been configured. The GET /api/auth/providers response will not include the scheme.
6. Session Management
6.1 Session cookie missing on request
What happens: 401 Unauthorized on authenticated endpoints.
Common causes for cross-origin SPAs: Cookie is SameSite=Strict so the browser did not include it.
Fix: Set CookieSameSite = "None" and CookieSecure = true. Ensure CORS allows credentials from the SPA origin. Send requests with credentials: "include" (fetch) or withCredentials: true (Axios).
6.2 Session revoked (JTI invalidated)
What happens: DELETE /sessions/{jti} marks the specified JTI as revoked in the IPrimusSessionRevocationStore. The cookie authentication middleware consults the revocation store on every authenticated request via an OnValidatePrincipal hook wired by AddPrimusAuthBroker. A revoked cookie is immediately rejected with 401 Unauthorized on the next request — there is no grace window.
Implication for your app: No additional configuration is required. Out of the box, InMemorySessionRevocationStore handles single-process deployments. For multi-node or distributed deployments, supply a custom IPrimusSessionRevocationStore backed by Redis or a shared database so revocations propagate across all instances.
When DELETE /sessions is used: The logout endpoint signs the cookie out of the current request and marks the JTI as revoked, providing immediate cross-request protection on the same instance.
6.3 Concurrent sessions
What happens: If you implement IPrimusSessionRevocationStore with a per-user session cap, you can track active JTIs and evict the oldest one when a new login would exceed your configured limit.
Audit event: SessionRevoked (actor = "System") — fired by your store implementation when it revokes an old JTI.
6.4 Cookie not sent over HTTP in development
What happens: The browser refuses to send the cookie because CookieSecure = true but the request is over HTTP.
Fix: Set CookieSecure = false in appsettings.Development.json only.
7. CSRF Protection
7.1 CSRF cookie not set before login
What happens: 400 Bad Request.
Fix: Call GET /api/auth/providers during app initialisation (e.g., in ngOnInit, useEffect, or main.ts), not just when the login page loads.
7.2 CSRF token in header does not match cookie
What happens: 400 Bad Request. The X-Primus-CSRF header value does not match the XSRF-TOKEN cookie.
Common cause: The XSRF-TOKEN cookie was not set before the request was sent — GET /api/auth/providers (or any other broker endpoint) had not been called yet, so no cookie existed.
Fix: Read the XSRF-TOKEN cookie value immediately before each mutating request and pass it in the X-Primus-CSRF header. The cookie is stable once set — it is not rotated between requests.
7.3 SameSite cookie policy conflicts with cross-origin setup
What happens: The CSRF cookie is not sent by the browser on cross-origin requests, so the double-submit check always fails.
Fix: For cross-origin SPAs, set CookieSameSite = "None" and CookieSecure = true.
8. Rate Limiting
8.1 Legitimate user locked out after 5 fast attempts
How to handle it: Show a clear error message with the remaining wait time (from Retry-After). Do not retry silently.
8.2 Shared IP (NAT / corporate proxy)
What happens: All users behind a corporate NAT share one IP. A single user triggering the rate limit locks out all colleagues temporarily.
How to handle it: Increase MaxAttempts and WindowSeconds, or implement a custom IPrimusAuthRateLimiter keyed by userId.
8.3 Rate limiter not distributed (multi-replica deployments)
What happens: Each instance has its own counter. An attacker with 2 replicas gets 5 + 5 = 10 attempts.
Fix: Implement a distributed rate limiter (e.g., Redis-backed) and register it as IPrimusAuthRateLimiter.
9. Domain Allowlist
9.1 User's email domain not in allowlist
What happens: 403 Forbidden with { "error": "Your email domain is not permitted to access this application." }.
Audit event: LoginBlockedDomain.
9.2 Allowlist enabled but AllowedDomains is empty
What happens: Every login attempt is blocked with 403.
Fix: Add at least one domain to AllowedDomains, or set DomainAllowlist.Enabled = false.
9.3 Subdomain not covered by allowlist
What happens: alice@mail.acme.com is blocked when only acme.com is in the allowlist.
Fix: The Broker performs an exact domain match. Add each subdomain separately: ["acme.com", "mail.acme.com"].
10. Passwordless (Email OTP, Magic Link, SMS OTP)
10.1 OTP code expired
What happens: POST /email-otp/verify or POST /sms/verify returns 422. The user must request a new code.
10.2 OTP code already used
What happens: 422 Unprocessable Entity. OTP codes are single-use.
10.3 Magic link token expired
What happens: GET /magic-link/verify?token=... returns 422 (default TTL: 15 minutes).
Recommended copy: "This link has expired. Please request a new one." — do not reveal whether the link was already used.
10.4 OTP used for a different email
What happens: 401 Unauthorized. An OTP sent to alice@example.com cannot be used to log in as bob@example.com.
10.5 Email OTP send endpoint called for unknown email
What happens: POST /email-otp/send always returns 200 regardless of whether the email exists. Your IPrimusEmailSender should silently skip sending if the user is not found.
11. Passkeys (WebAuthn)
11.1 Device does not support WebAuthn
How to handle it: Check for navigator.credentials availability before offering passkey registration. Provide a fallback (password or OTP).
11.2 Passkey used on a different device
What happens: Verification fails because the credential is not in the user's registered list.
Passkeys are generally device-bound (Touch ID, Windows Hello). Synced passkeys (Apple, Google) work across devices within the same ecosystem.
11.3 Passkey challenge expired
What happens: POST /passkey/login/complete returns 422. The challenge (from /passkey/login/begin) TTL is 5 minutes (300 000 ms — controlled by PrimusAuth:Passkeys:TimeoutMs, default 300000). The user must restart the flow.
12. Impersonation
12.1 Non-admin tries to impersonate
What happens: POST /impersonate returns 403 Forbidden. The Broker checks the roles claim for "Admin".
12.2 Target user not found
What happens: 404 Not Found.
12.3 Admin tries to impersonate themselves
What happens: No explicit prevention — but it serves no purpose. Consider adding a guard in your admin UI.
12.4 Impersonation safety note
Impersonation is an admin tool for support/debugging on the admin's own browser. It is not a mechanism for the admin to act as a user on the user's own device.
13. Organization Switch
13.1 User switches to an org they are not a member of
What happens: POST /org/switch returns 403 Forbidden. The Broker validates org membership before re-issuing the session.
13.2 Session claims become stale after org switch
What happens: A new cookie is issued with the updated orgId. The old JTI is revoked immediately.
How to handle it: After a successful org switch, reload the page or navigate to the post-switch destination.
14. Password Policy Validation
14.1 Weak password rejected
What happens: POST /password/validate returns 200 OK with isValid: false and a list of unmet requirements. The endpoint never returns 4xx for policy failures — the HTTP status indicates whether the request was processed, not whether the password is strong.
{
"isValid": false,
"errors": ["Password must be at least 8 characters long.", "Password must contain at least one special character."]
}
How to use this: Call this endpoint in real-time as the user types to provide instant feedback.
14.2 HIBP breach detection failure
What happens: When EnableBreachDetection = true and the HIBP API is unreachable, the Broker fails open by default — the password is accepted.
If you need fail-closed behaviour: Implement your own IPrimusPasswordValidator.
14.3 Password exactly meets minimum length
What happens: Returns valid: true, score: 1. Consider discouraging scores below 3 in your UI.
15. Configuration Mistakes
15.1 TokenSecret is too short
What happens: InvalidOperationException at startup: "PrimusAuthBroker Security:TokenEncryptionKey must be at least 32 characters long for AES-256 security."
Fix: Generate a cryptographically random secret:
[System.Convert]::ToBase64String([System.Security.Cryptography.RandomNumberGenerator]::GetBytes(32))
15.2 TokenSecret committed to source control
What happens: No runtime error, but anyone who reads your repository can forge session JWTs.
Fix:
- Rotate the secret immediately (all existing sessions are invalidated — all users will be logged out).
- Store it in
dotnet user-secrets(local dev) or your CI/CD secrets manager (production). - Reference via environment variable:
PrimusAuth__Security__TokenEncryptionKey=<your-32+-char-secret>
15.3 CookieSecure = false in production
What happens: Sessions work over HTTP, but the cookie is transmitted in plaintext.
Fix: Set CookieSecure = true in appsettings.Production.json.
15.4 Diagnostics endpoint exposed in production
What happens: GET /api/auth/__auth/diagnostics (i.e., {basePath}/__auth/diagnostics) returns internal user store information and is readable by anyone. This endpoint is only registered when the environment is Development.
Fix: This endpoint is only active in the Development environment — it is not registered in Production or Staging. Ensure ASPNETCORE_ENVIRONMENT is Production in prod deployments.
15.5 Wrong base path in MapPrimusAuthBroker
What happens: All broker endpoints return 404.
Fix: Ensure the basePath argument to MapPrimusAuthBroker() matches the prefix your frontend uses. The quickstart default is /api/auth.
Next Steps
- How It Works — architecture deep dive and flow diagrams
- Endpoint Reference — complete endpoint documentation with request and response shapes
- Integration Guide — Angular and React frontend integration walkthrough
- Advanced Configuration — custom stores, HA clustering, and data protection