BlogSoftware Development
Software Development

The Authentication Flow We Stopped Reinventing

Six providers, refresh-token families, and 2FA without the 2 AM pages — the design patterns we baked into Nedo.AspNet.Authentication after three rewrites.

Sindika Engineering May 1, 2026 11 min read

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.

Before: One App, Six Auth Stacks, Zero SleepYour APIspaghetti editionGoogleMicrosoftAppleLocal + JWTRefresh tokensTOTP 2FA⚠ Six callback handlers · three token shapes · zero shared lockout policy

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.

Two Patterns, One APIPattern A — Mint Your Own JWTSocial + Local · SPA-friendlySPAGoogle / Apple / LocalVerify upstreamID token / OAuth codeIssue Nedo JWTyour sub, your audYour API trusts itone shape foreverPattern B — JWT BearerEnterprise OIDC · API as resourceFrontend signs inKeycloak / Entra / Auth0 / OktaIdP issues JWTdirectly, no exchangeAPI validates JWTJWKS, audience, issuerClaims → Nedo contextroles flatten, tenant resolves

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.

Refresh Token Families: One Bad Token, Whole Session BurnsRT₁issued at login✗ rotatedRT₂rotation 1✗ rotatedRT₃rotation 2 (current)✓ liveSame family · linked by rotation historyAttacker replays RT₂already-rotated token🔥 Reuse detected → entire family revoked, user signed out everywhere

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 TokenCleanupService keeps 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.

TOTP Auto-Gates Every Sign-In PathLocal passwordGoogleMicrosoftGitHub / AppleIMultiFactorChallengechecks user.TotpEnabledbefore any token issuesNo 2FA→ JWT issued2FA on→ TOTP promptOne interface. Every provider. Zero per-flow copy/paste.

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.

ProviderPatternAudienceNotes
GoogleMintSocial SPAOIDC ID-token exchange
MicrosoftMintSocial SPAMSAL.js, multi-tenant
AppleMintSocial SPAApple JS SDK, .p8 key
GitHubMintSocial SPAOAuth popup + postMessage
FacebookMintSocial SPAGraph API token check
LocalMintUsername/passwordRefresh families, lockout, reset
KeycloakBothHybridSign-in OR bearer-only
Entra IDBearerEnterpriseAzure app roles, groups
Auth0BearerEnterpriseNamespaced claims, RBAC
OktaBearerEnterpriseAuth 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:

🏷️
Step 1

Resolve tenant

From subdomain, header, claim, or route

🔍
Step 2

Verify upstream

OIDC ID token, OAuth code, or local password

🛡️
Step 3

Multi-factor gate

IMultiFactorChallenge runs before any token issues

🎟️
Step 4

Mint or accept JWT

Mint Nedo JWT (social/local) OR accept upstream (enterprise)

🔧
Step 5

Transform claims

Flatten roles, normalize email, attach tenant

💧
Step 6

Hydrate IAuthContext

Per-request scoped, mockable for tests

📡
Step 7

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:

Which Pattern Do You Need?Your projectSign in via your app?(social / password)YesNoBearervalidate JWTsAlso accept enterpriseIdP tokens?NoMintissue Nedo JWTYesHybridcompose Mint + Bearer

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.