It's 2 AM. A customer can't sign in. The Google popup opens, closes, and nothing happens. Your refresh-token endpoint is silently issuing the same token twice. And somewhere in the logs, a TOTP code is being checked after the JWT has already been minted.
Welcome to authentication — the feature every team underestimates and every team eventually rebuilds. We've been there. Three times, actually. Each time we said “this one will be clean.” Each time, six weeks later, the auth folder looked like a parking lot after a thunderstorm.
So we stopped rebuilding. We extracted the patterns that survived production into a library: Nedo.AspNet.Authentication. This article is the story of why — and the design decisions that finally made auth feel boring again. Boring is good. Boring is what you want at 2 AM.
“Authentication is the part of your codebase where every shortcut taken on Tuesday becomes a security incident on Sunday. We built this so neither of those days has to be exciting.”
— Sindika Engineering Team
Chapter 1: How Auth Becomes a Parking Lot
The first version of auth in any project is innocent. A login endpoint. A bcrypt hash. A signed JWT with a 1-hour lifetime. Beautiful. Forty lines of code.
Then the product manager walks over.
- “Can we add Sign in with Google?”
- “Enterprise customer wants Microsoft too.”
- “Apple just rejected our app — we need Sign in with Apple.”
- “Procurement is asking about SSO via Keycloak.”
- “Security wants 2FA.”
- “A customer's account got compromised. We need refresh-token rotation.”
- “We're going multi-tenant next quarter.”
Now your AuthController has six callback endpoints, three different token shapes, and two services that both think they own “the user.” Lockout policy lives in one place, refresh-token logic in another, and 2FA is bolted onto the password flow but silently skipped on social sign-in because nobody remembered to wire it up.
Six providers. Three token shapes. Zero shared lockout policy. We've seen this codebase. We've written this codebase.
The problem isn't any single piece. It's that authentication is actually seven different problems that share one entry point — and most teams treat them as one big problem. The result is what we like to call “auth.cs: the file” — a 1,400-line monument to good intentions.
Chapter 2: There Are Only Two Patterns
Once you stare at enough auth flows, a hidden truth emerges: there are only two patterns. Every provider — Google, Apple, Keycloak, Auth0, your local password form — collapses into one of them.
Pattern A — Mint your own JWT. The user proves who they are with Google (or Apple, or a password). You verify the proof. Then you issue your own JWT — your sub, your aud, your expiry. Your API only ever validates one shape of token, regardless of how the user signed in. SPA-friendly, refresh-friendly, and trivially testable.
Pattern B — JWT bearer. The frontend talks to an enterprise IdP (Keycloak, Entra ID, Auth0, Okta) and gets a JWT directly from it. Your API never issues tokens — it just validates them via JWKS, checks audience, and trusts the upstream issuer. Right when the IdP is the source of truth and you're building a pure resource server.
Mint or accept. The library makes both feel like the same line of code.
Most apps need both. A SaaS product might mint JWTs for self-service signups (Pattern A) and accept Keycloak tokens for enterprise customers (Pattern B) — at the same endpoint. The library lets you compose the two without writing two parallel auth stacks.
// Mint your own JWTs (Pattern A) — local + Google + Microsoft
builder.Services.AddLocalAuthentication(opts =>
{
opts.SigningKey = config["Jwt:Secret"]!;
opts.AccessTokenLifetime = TimeSpan.FromMinutes(15);
opts.RefreshTokenLifetime = TimeSpan.FromDays(7);
});
builder.Services.AddGoogleSignIn(opts => opts.ClientId = "...");
builder.Services.AddMicrosoftSignIn(opts => opts.ClientId = "...");
// And accept Keycloak JWTs (Pattern B) on the same API
builder.Services.AddNedoAuthentication(opts =>
{
opts.Authority = "https://keycloak.example.com/realms/my-realm";
opts.Audience = "my-api";
});Chapter 3: The Refresh-Token Story Nobody Tells You
Refresh tokens are the part of auth where well-meaning teams accidentally build a backdoor. The classic mistake: store one long-lived refresh token, accept it forever. If it leaks once, the attacker has permanent access — and you'll never know.
The fix is older than most engineers realize: refresh-token families with reuse detection. Every refresh issues a fresh token. The old one is marked rotated. If anyone ever presents an already-rotated token, you don't just reject that request — you burn the entire family.
A replayed refresh token is a smoke signal. It means the legitimate user has the new token — and someone else has an old one.
The user gets signed out everywhere. They log in again. The attacker's stolen token is now scrap metal. From a UX perspective it's a mild inconvenience. From a security perspective it turns refresh-token theft from a silent catastrophe into a loud, recoverable event.
🤔 Why this is harder than it looks
- ▸Race conditions. Two browser tabs refresh at the same time. Both have the same valid token. One wins, the other reuses — and now you just signed the legitimate user out. The fix: a small grace window where the previous token is still acceptable.
- ▸Token storage. Hash refresh tokens at rest. If your DB leaks, the tokens shouldn't be replayable.
- ▸Family pruning. Old families pile up forever. A periodic
TokenCleanupServicekeeps the table small without operator effort.
Chapter 4: 2FA That Cannot Be Forgotten
Here's a fun production story. A team adds TOTP 2FA. They wire it into the password login. Six months later, security review finds that social sign-in still skips it entirely. The Google flow was added by a different person, in a different sprint, and nobody remembered to call the TOTP check.
The architectural fix is to make 2FA impossible to forget. We do this with a single interface — IMultiFactorChallenge — that sits between “identity verified” and “token issued.” Every sign-in path, regardless of provider, has to walk through it. Social, password, magic-link, dev-impersonation — all of them.
The 2FA gate is part of the pipeline, not a thing you remember to call.
The principle generalises: if you have a security check that any sign-in flow must pass, make the type system enforce it. The compiler will catch the next dev who adds “Sign in with LinkedIn” and forgets the gate. Not your security review. Not your customers.
Chapter 5: One Library, Ten Providers, Pay-As-You-Go
The other lesson from years of auth code: nobody needs all the providers. A startup needs Google + Local. An enterprise SaaS needs Keycloak + Entra. A consumer app needs Apple + Google + Facebook. Forcing every project to ship every provider is how dependency trees become two megabytes of unused code.
So the platform is a constellation of small NuGet packages — each provider is its own package, each cross-cutting concern is its own package. You install only what you use.
| Provider | Pattern | Audience | Notes |
|---|---|---|---|
| Mint | Social SPA | OIDC ID-token exchange | |
| Microsoft | Mint | Social SPA | MSAL.js, multi-tenant |
| Apple | Mint | Social SPA | Apple JS SDK, .p8 key |
| GitHub | Mint | Social SPA | OAuth popup + postMessage |
| Mint | Social SPA | Graph API token check | |
| Local | Mint | Username/password | Refresh families, lockout, reset |
| Keycloak | Both | Hybrid | Sign-in OR bearer-only |
| Entra ID | Bearer | Enterprise | Azure app roles, groups |
| Auth0 | Bearer | Enterprise | Namespaced claims, RBAC |
| Okta | Bearer | Enterprise | Auth server, group → role |
Multi-tenancy is the same idea applied to identity. Tenants can be resolved from a subdomain, a header, a JWT claim, or a route segment — whatever your gateway happens to send. The auth context exposes TenantId as a first-class property, so the rest of your application doesn't need to think about it.
app.MapGet("/me", (IAuthContext auth) => Results.Ok(new
{
auth.UserId,
auth.Email,
auth.Roles,
auth.TenantId // resolved from header / claim / subdomain
}));Chapter 6: The Authentication Pipeline
Zoom out and a single sign-in request flows through a seven-step pipeline. Each step is independent and replaceable, but together they enforce the invariants that took us three rewrites to internalise:
Resolve tenant
From subdomain, header, claim, or route
Verify upstream
OIDC ID token, OAuth code, or local password
Multi-factor gate
IMultiFactorChallenge runs before any token issues
Mint or accept JWT
Mint Nedo JWT (social/local) OR accept upstream (enterprise)
Transform claims
Flatten roles, normalize email, attach tenant
Hydrate IAuthContext
Per-request scoped, mockable for tests
Emit audit event
IAuthEventSink for sign-ins, failures, lockouts
Notice that the 2FA gate (step 3) sits before token issuance (step 4), and that audit emission (step 7) happens regardless of outcome. These two constraints — “no token before MFA” and “every attempt is logged” — are the two production-incident lessons that we now have built into the pipeline shape itself.
Chapter 7: Which Pattern For Your Project?
The hard part isn't writing auth — it's deciding which shape of auth you actually need. Two questions get you 90% of the way there:
If your customers already have an enterprise IdP, don't fight it. Bearer pattern. Validate their JWTs, mirror their roles, move on.
If you're building consumer or mid-market and need social or password sign-in, mint your own. One token shape inside your system, no matter how the user got in.
And if you need both? You compose them. The two patterns aren't exclusive — they're two endpoints in the same auth pipeline. The library exists because teams shouldn't have to choose at the start of a project, before they know which customers they'll have.
“Auth shouldn't be the part of the codebase your senior engineers avoid. Make the easy thing easy, make the safe thing the default, and make the unsafe thing impossible to write by accident.”
— Sindika Engineering Team
The Bottom Line
Authentication isn't one feature — it's a small platform inside your platform. Treat it that way and the 2 AM pages stop. Treat it as a checkbox and you'll keep getting paged.
Two patterns: mint or bearer. Refresh families with reuse detection. A 2FA gate the type system enforces. Modular providers. Multi-tenant from day one. That's the shape of an auth stack you can stop rewriting — and the shape we baked into Nedo.AspNet.Authentication.