Skip to content

work / auth-architecture · engineering case study · 2026-07

Authentication Architecture — One Core, Two Edges, Every Session Revocable

One authentication core that decides *who you are*, two transport edges that each receive the session artifact safest for their medium, and the one design goal that separates a good auth system from a top-1% one: every session — cookie or token, web or mobile — must be revocable and enumerable server-side.

Sole engineer of Partners.com.bd — I design, build, and run the whole platform. This is the authentication slice: architected across two ADRs after a four-stream R&D review — one decision core, two transport edges, revocable sessions on both, and a JWKS issuer boundary — with a full Critical/High hardening set under **832 tests**.

[dotnet][security][identity][oauth][sqlserver]

4 → 1

login paths, one decision core

2 + 6

Criticals + Highs closed in the hardening set

832

tests green across app + infra

2

accepted ADRs after a 4-stream R&D review

2

edges with revocable, enumerable sessions

In one minute

When you log in, something has to decide who you are and then hand your client a session artifact — a cookie or a token. Most systems make two mistakes. They scatter that decision across every login path — web password, web Google, mobile, admin — so the same bug has to be fixed four times. And they issue a session the server keeps no record of, so once it's out, nobody can kill it.

This project makes the decision singular: one MediatR core over ASP.NET Core Identity, and every surface routes through it. And it makes the session revocable on both edges: a SQL-backed server-side store for the web cookie, and refresh-token reuse-detection with family-revocation for mobile — unified into one "Active Sessions & Devices" list, the way GitHub shows you every logged-in device. It's an architecture that treats revocation as a first-class property rather than an afterthought, sitting on a Critical/High security-hardening set under 832 tests.

01

One login, four code paths, and a session nobody could kill

The product ships two clients over one system: a server-rendered ASP.NET Core MVC web app (cookie auth) and a Flutter mobile app on a JWT + refresh-token API, with admin logging in on the web too. The authentication decision had drifted apart across them. Mobile and the web password login already routed through the shared core — but the web Google login still ran through the legacy OnlineShop.Repository.GoogleSignUp, and admin login was still legacy. A fix made in one place didn't reach the others.

Worse is the session itself. The web session is a stateless, self-contained cookie: the entire Identity authentication ticket is serialized into the cookie, Data-Protection-encrypted, with a 7-day sliding expiry and no absolute timeout — and the server keeps no record of it. OWASP calls server-side invalidation the "most relevant and mandatory" part of session management, and it is exactly the property this session lacks.

symptom → cause

A stolen web cookie could not be killed

Because the whole ticket lives in the cookie and the server stores nothing, a stolen cookie is valid until it expires and there is no way to revoke it. "Log out everywhere" is impossible — there's nothing server-side to delete. An admin can't terminate a compromised account's live sessions, a password change can't invalidate other sessions, and the user can't be shown their own active devices, because no such list exists.

symptom → cause

The email-verification "token" was the user's primary key

The legacy partner email-verification link used the user's primary key as its "token" — no entropy, no expiry, guessable. Anyone could activate any inbox by walking IDs. It was one of the two Criticals the auth review confirmed; the fix replaces it with a real, single-use, SecurityStamp-bound Identity email-confirmation token validated through ConfirmEmailAsync.

symptom → cause

Reset links trusted the attacker's Host header

Password-reset and verification links were built from the attacker-controllable Request.Host with AllowedHosts set to "*" — a classic reset-poisoning vector (and the API's link even pointed at a 404 route). The fix builds every link server-side from a per-country configuration via an IAppUrlBuilder, and pins AllowedHosts to real domains.

02

Auth is one decision, many transports — and revocation is what separates good from top-1%

Any framework hands you a login form and a token library. What they don't hand you is the property that actually separates a good auth system from a top-1% one: every session must be revocable and enumerable server-side. That, plus refusing to scatter the login decision, is the whole thesis.

So the design is three moves. One core decides who you are — credential verification, Google-token validation, lockout, account-creation policy, token issuance — all in Partners.Application MediatR handlers over ASP.NET Identity, with every surface routing through it. Each edge gets the artifact safest for its medium — an HttpOnly cookie for the server-rendered browser, a bearer JWT + rotating refresh for native mobile. And every session is revocable on both edges — a SQL-backed server-side store for the web cookie, reuse-detection with family-revocation for mobile — unified into one session model. The sharpest corollary: "same artifact everywhere" is the anti-pattern — the decision is what must be single, not the wire format.

01

One decision core

Every auth decision lives in `Partners.Application` MediatR handlers over ASP.NET Identity — credential verify, Google-token validation, lockout, creation policy, issuance. A bug fixed in a handler fixes web and mobile at once. No decision logic left in `OnlineShop.Repository`.

02

Two transport edges

Browser → HttpOnly + Secure + SameSite cookie; a server-rendered app has no browser-JS token store, so it already has the security property a Backend-for-Frontend gives a SPA. Native mobile → bearer JWT access + rotating refresh (RFC 8252 public client). The edge translates the core's one decision into the right artifact.

03

Revocable web sessions

A custom `ITicketStore` moves the ticket server-side; the cookie then carries only an opaque 64-bit session key, and `dbo.UserSession` is the SQL store of record. Delete a row → instant revocation; delete every row for a `UserId` → log-out-everywhere, admin account-kill, kill-on-password-change.

04

Revocable mobile sessions

Refresh tokens carry a `FamilyId`; presenting an already-rotated token outside a small grace window revokes the whole family plus active access tokens. Rotation without reuse-detection buys little.

05

One unified session model

Web sessions (`dbo.UserSession`) and mobile refresh-token *families* become a single enumerable "Active Sessions & Devices" screen — the GitHub/Google experience across both edges.

06

JWKS issuer boundary

Move token trust from a shared symmetric secret / shared `dbo.DataProtectionKeys` to asymmetric ES256/RS256 signing plus a `.well-known/jwks.json` discovery endpoint; each app validates with the issuer's *public* key. Decouples the hosts and turns a future OIDC server into a config change, not a rewrite — the architectural pivot the whole boundary is built around.

07

Shipped hardening floor

A Critical/High set already landed: real Identity email-confirmation tokens, `email_verified`-gated Google auto-link, server-side per-country reset URLs, the rate-limiter reordered after authentication so user-partitioned limiters don't collapse to the IP bucket, and enumeration-safe admin logins with a PBKDF2 timing-equalizer — all under 832 tests.

03

One core decides; each edge gets the artifact safest for its medium

At the center is one rule: no controller decides who you are. Every login — web password, web Google, mobile, admin — becomes a MediatR command (LoginCommand, GoogleLoginCommand) that verifies the credential or validates the Google token, checks account state and lockout, applies the account-creation policy, and returns a single decision. The controller at each edge then translates that decision into its medium's artifact: SignInWithCookieAsync establishes the HttpOnly cookie for the browser; the mobile response carries a JWT and a rotating refresh token. Because the decision is singular, the same email_verified gate, the same find-or-create, and the same issuance policy apply everywhere — and the JWKS boundary lets each host validate a token by the issuer's published public key instead of a shared secret, so trust stops depending on who minted it.

One decision core, two transport edges, and a JWKS issuer boundary At the top are two clients: the browser (MVC web and admin) on the left, and the native mobile app on the right. Both route their login through one central Auth core — MediatR handlers over ASP.NET Core Identity — which makes a single decision: verify the credential, validate the Google token, check lockout, issue. The core then hands each edge the session artifact safest for its medium: the browser edge gets an HttpOnly, Secure, SameSite cookie (no browser-JavaScript token store, so it already has the Backend-for-Frontend property); the native edge gets a bearer JWT plus a rotating refresh token. Across the bottom is the JWKS issuer boundary — asymmetric ES256 signing plus a dot-well-known slash jwks.json discovery endpoint — which lets each host validate a token by the issuer's public key instead of a shared secret, so a drop-in OIDC server later is a config change rather than a rewrite. Hover or focus any node to read it. one decision · two edges by medium · one JWKS trust boundary Browser · MVC web + admin (no browser-JS token store) Browser · MVC web + admin server-rendered · no JS token store Native mobile · Flutter (RFC 8252 public client) Native mobile · Flutter RFC 8252 public client Auth core · MediatR over Identity — one decision Auth core · MediatR over Identity one decision: verify · validate · lockout · issue a bug fixed here fixes web + mobile at once HttpOnly + Secure + SameSite cookie HttpOnly + Secure + SameSite cookie BFF property for free · no XSS token surface bearer JWT + rotating refresh token bearer JWT + rotating refresh held in the app · never in a browser JWKS issuer boundary — validate by public key, not a shared secret JWKS issuer boundary · ES256 + /.well-known/jwks.json each host validates by the issuer's public key, not a shared secret the trust seam — a drop-in OIDC server later is a config change, not a rewrite hover a node ▸
Fig. 1 — one core, two edges: a single MediatR-over-Identity decision core sits in the middle; the browser edge translates its decision into an HttpOnly Secure SameSite cookie while the native mobile edge gets a bearer JWT plus a rotating refresh token, and a JWKS issuer boundary (asymmetric ES256 signing plus a .well-known/jwks.json discovery endpoint) lets each host validate tokens by the issuer's public key instead of a shared secret — so a drop-in OIDC server later is a config change, not a rewrite
04

Follow one web Google login through the converged path

  1. 1

    1. The browser obtains a Google access token

    browser edge

    The web Google button uses the implicit flow, which yields an access token — not an ID token. (The mobile app, by contrast, gets an ID token.) The two clients arrive at the same core carrying two different Google artifacts.

  2. 2

    2. The callback hits one shared handler

    one core

    HomeController.GoogleCallback now sends _mediator.Send(GoogleLoginCommand) — the very same handler the mobile API uses. GoogleLoginCommand.IdToken is optional and an optional AccessToken was added, so the handler validates whichever artifact the edge supplied.

  3. 3

    3. The token is validated *and* audience-checked

    verified

    For the web access token, ValidateAccessTokenAsync calls Google's tokeninfo and checks the audience against the web client ID — a check the legacy userinfo-only path lacked, which closes an access-token substitution / confused-deputy vector — then reads the profile. email_verified must be true before any account match or create.

  4. 4

    4. Find-or-create runs, shared and unchanged

    shared

    Everything downstream — the verified-email gate, find-or-create, token issuance — is the code every edge already shares. There is no second Google path to keep in sync.

  5. 5

    5. The edge issues the cookie

    web artifact

    The callback ignores the JWT and refresh token in the command's response (that's the mobile edge's artifact) and establishes the HttpOnly cookie via SignInWithCookieAsync. The front-end button and redirect are unchanged — only the server moved — which is the smallest possible blast radius for a live login path.

The converged Google login: two token types, one shared handler Two clients arrive with two different Google artifacts. The web implicit flow yields an access token; the native mobile app yields an ID token. Both reach one shared GoogleLoginCommand handler — the same handler the mobile API already used. The handler validates whichever artifact was supplied: the web access token is validated through Google's tokeninfo endpoint with an audience check against the web client ID, which closes an access-token substitution or confused-deputy vector that the legacy userinfo-only path lacked; the mobile ID token path is unchanged. Then email_verified must be true before any account match or create, the shared find-or-create and token issuance run, and finally each edge issues its own artifact — an HttpOnly cookie for web, a JWT plus refresh for mobile. This convergence onto one handler is what fixed the already-logged-in bug. Hover or focus any step to read it. two Google artifacts, one shared handler · one decision Web · implicit flow → access token web · implicit flow → access token Mobile → ID token (unchanged) mobile → ID token (unchanged) GoogleLoginCommand · one shared handler GoogleLoginCommand · one shared handler validates whichever artifact the edge supplied Validate · web access token audience-checked via tokeninfo validate the supplied artifact web access token: tokeninfo + audience check vs web client ID closes a substitution vector the legacy userinfo path lacked email_verified must be true — before match/create email_verified must be true → then match / create shared find-or-create + issuance shared find-or-create + issuance — no second Google path web edge → HttpOnly cookie (SignInWithCookieAsync) web → HttpOnly cookie SignInWithCookieAsync · JWT ignored here mobile edge → JWT + refresh mobile → JWT + refresh in the response body hover a step ▸
Fig. 2 — the converged Google login: the web implicit flow yields an access token and mobile yields an ID token, but both reach the one shared GoogleLoginCommand handler, which validates whichever artifact was supplied (the web access token is audience-checked via Google tokeninfo, closing a substitution vector the legacy userinfo path lacked), gates on email_verified, runs the shared find-or-create, then lets each edge issue its own artifact — a cookie for web, a JWT for mobile
05

Where the security thinking went

Every one of these had a tempting shortcut. Choosing the harder-but-correct path — and being able to say why — is the difference between code that works in a demo and code that survives production.

One decision core, not one source of truth per client

chose: every surface routes through the same MediatR handlersover: each client keeping its own login logic

Scattered login logic means the same bug is fixed four times and usually isn't. Centralizing the decision means a fix in a handler protects web and mobile at once — which is exactly why the live Google bug was closed by convergence, not by a patch to the broken path.

Two edges by medium — cookie for web, token for mobile

chose: the artifact safest for each mediumover: "same artifact everywhere" (all JWT, or all cookie)

A JWT sitting in browser JavaScript is an XSS token-theft target; a cookie in a native app is the wrong medium. A server-rendered browser app already keeps no token in JS, so it has the BFF security property for free. The decision must be singular — the wire format must not be.

SQL as the session store of record, Redis deferred

chose: `dbo.UserSession` on the SQL + Dapper stack we already runover: requiring Redis now

Redis is currently off and the current shared host can't guarantee it, so blocking a security-critical feature on infrastructure outside our control is the wrong dependency. SQL is durable, enumerable, and already here; an indexed single-row lookup keeps revocation always-instant. Redis later is a config-only flip (fill the connection string → HybridCache L1+L2), not a redesign.

Keep the IdP in-house; build the JWKS seam, defer the IdP

chose: asymmetric + JWKS now, OpenIddict later on the same `AspNetUsers` storeover: a managed cloud IdP

No managed provider has a Bangladesh region — a hard collision with BD data-localization — Auth0/Cognito hit a six-figure cost cliff at millions of MAU, and Entra can't import ASP.NET Identity password hashes, forcing a mass reset. Building the JWKS boundary now keeps the IdP a drop-in later without paying for it, or migrating users, today.

The war story · the login that fought back

The bug that logged you in, then told you that you were already logged in

The most dangerous bug in the whole system wasn't a crash — it was a login that succeeded and then denied it. On the web Google path, the legacy UserRepository.GoogleSignUp signed the user in — it set the auth cookie — and then threw, on an unregistered "Cookies" scheme plus an EF duplicate-tracking _db.Update. The throw was swallowed, the method returned Success=false, and the callback redirected the now-signed-in user back to a login page — which saw the fresh cookie and announced "A user is already logged in this browser." A user who did everything correctly was trapped in a loop, simultaneously logged in and told they couldn't log in.

The root cause was structural, not a typo: the web Google login was the one remaining user auth path still running through the legacy repository instead of the shared core — the same bug family as an earlier tracking-collision hotfix that had covered eight other actions but missed this one. So the fix wasn't to catch the swallow; it was to converge. GoogleCallback now delegates to the shared GoogleLoginCommand handler, validates the browser's access token with an audience check the legacy path never did, and issues the cookie cleanly at the edge — closing the whole family at once. Then a second, adversarial review of the hardening set caught a regression of exactly this shape: a reorder that put PasswordSignInAsync before the role check, so an exception in the role lookup could return "Login Failed" with a live auth cookie still set. The fix made await SignOutAsync() the first line of every login catch block across all six admin controllers — a no-op if no sign-in happened, a guarantee otherwise. The same lesson, twice: a login that half-succeeds is worse than one that cleanly fails.

The login that fought back: a swallowed-throw loop, and the fix A five-step failure loop on the legacy web Google login. Step one: the legacy GoogleSignUp signs the user in and sets the auth cookie. Step two: it then throws, on an unregistered Cookies authentication scheme plus an Entity Framework duplicate-tracking _db.Update. Step three: the throw is swallowed and the method returns Success equals false. Step four: the callback redirects the user to a login page. Step five: the login page sees the fresh cookie and announces A user is already logged in this browser — which sends the user back around, a loop where they are simultaneously logged in and told they cannot log in. Below, the fix: converge the path onto the shared GoogleLoginCommand handler so the cookie is issued cleanly at the edge, closing the whole bug family. Hover or focus any step to read it. signed in, then told you were already signed in — a loop 1 · GoogleSignUp signs you in — cookie SET 1 GoogleSignUp signs you in — auth cookie SET 2 · then throws: 'Cookies' scheme + EF _db.Update 2 then throws: unregistered “Cookies” scheme + EF _db.Update 3 · throw swallowed → returns Success=false 3 throw swallowed → returns Success=false 4 · callback redirects → login page 4 callback redirects → login page 5 · login sees the cookie → “already logged in this browser” 5 login sees the fresh cookie → “already logged in this browser” signed in, and told you can't sign in — the loop loop fix · converge onto the shared handler — cookie issued cleanly the fix: converge onto the shared GoogleLoginCommand cookie issued cleanly at the edge — the whole bug family closed root cause was structural: the one user path still on the legacy repo a login that half-succeeds is worse than one that cleanly fails hover a step ▸
Fig. 3 — the login that fought back: the legacy GoogleSignUp set the auth cookie, then threw on an unregistered Cookies scheme and an EF duplicate-tracking _db.Update; the throw was swallowed, the method returned Success false, and the callback redirected the now-signed-in user back to a login page that saw the fresh cookie and said A user is already logged in this browser — a loop, fixed by converging the path onto the shared core
06

One core, revocable sessions, and a top-1% security floor

The foundation is real and load-bearing. Every user login routes through one core — which is what fixed the "already logged in this browser" bug by construction, not by patch. On top of it, a full Critical/High hardening set: two Criticals and six Highs plus twelve review findings, closing the PK-as-verification-token hole, silencing a mobile logger that had been writing passwords and OTPs to the device log, gating Google on email_verified, moving reset URLs server-side per country, reordering the rate-limiter after authentication so user-partitioned limiters stop collapsing to the IP bucket, making the admin logins enumeration-safe with a PBKDF2 timing-equalizer and a sign-out-on-any-failure guard, and unifying the Data-Protection key ring so cross-app token flows stop silently failing — all under 832 automated tests.

And the architecture around it is the part that ages well. Revocation is a first-class property on both edges, not something bolted on after a breach; the JWKS boundary means a dedicated OIDC server is a config change rather than a rewrite; and the decision to keep the IdP in-house is what keeps the whole thing free of a residency problem, a per-MAU cost cliff, or a forced password-hash migration. It's a security design that reaches for the top-1% property — every session killable, server-side — and builds the seams that let the rest arrive without a rebuild.

The auth architecture: foundation, session & trust pillars, and what it defers by design The authentication architecture in three groups. The foundation, in teal: one decision core that every login routes through, and a Critical and High hardening set under 832 tests. The session and trust pillars, in purple: converging admin login onto the core, refresh-token reuse-detection with family revocation, the server-side revocable session store via a custom ITicketStore and a dbo.UserSession table, the NIST 800-63B password model, step-up MFA on the money paths, and the JWKS ES256 issuer boundary — the trust pivot. Deferred by design, in muted dashed, each adopted only on a real trigger: Redis as an L2 cache when scaling to multiple web instances, a dedicated OpenIddict IdP when SSO or B2B federation arrives, a Duende BFF only if the web becomes a browser-JS SPA, and DPoP once tokens cross real trust boundaries. Hover or focus any item to read it. foundation · session & trust pillars · deferred by design THE FOUNDATION SESSION & TRUST PILLARS DEFERRED BY DESIGN one decision core · every login routes through it one decision core every login routes through it Critical/High hardening set · 832 tests the hardening set 2 Crit + 6 High · 832 tests admin login → the same core admin login → core refresh reuse-detection + family revocation refresh reuse-detection FamilyId · a reused token kills the family revocable session store · ITicketStore + dbo.UserSession revocable session store ITicketStore + dbo.UserSession · delete a row = revoke NIST 800-63B password model NIST 800-63B passwords step-up MFA on money paths step-up MFA JWKS ES256 · the trust pivot JWKS ES256 — the trust pivot a drop-in OIDC server becomes a config change Redis L2 · on multi-instance scale (config-only flip) Redis L2 cache OpenIddict IdP · on SSO / B2B federation OpenIddict IdP same AspNetUsers store · the JWKS seam is the prereq Duende.BFF · only if web becomes a browser-JS SPA Duende.BFF DPoP · once tokens cross real trust boundaries DPoP (RFC 9449) revocable, enumerable sessions on both edges revocable, enumerable sessions on both edges the web session store + mobile refresh-family revocation → one “Active Sessions & Devices” screen the property that separates a good auth system from a top-1% one two accepted ADRs after a 4-stream R&D review hover an item ▸
Fig. 4 — the architecture at a glance, in three groups: the foundation (one decision core every login routes through, and the Critical/High hardening set under 832 tests); the session and trust pillars (admin convergence, refresh-token reuse-detection with family revocation, the revocable session store via ITicketStore + dbo.UserSession, the NIST password model, step-up MFA, and the JWKS ES256 trust pivot); and what it defers by design, each adopted only on a real trigger — Redis, a dedicated OpenIddict IdP, a Duende BFF, and DPoP
07

The thinking is written down, not just held: two ADRs (the authentication architecture; the server-side revocable session store) and a hardening change-set doc that lists every fix with its file and severity. The design is deliberately boring where security lives — one decision core, two edges by medium, one store of record, one unified session model. Just as deliberate is what it doesn't build: Redis stays a config-flip for when we scale to multiple web instances, a dedicated OpenIddict IdP waits until SSO or B2B federation actually arrives, a BFF is reserved for the day the web becomes a browser-JS SPA, and DPoP for when tokens cross real trust boundaries — each an option the JWKS seam keeps open without paying for it early. It's grounded in RFC 8252 / 9700 / 9449, NIST SP 800-63B Rev. 4, and the OWASP session and authentication cheat sheets — the standards that separate an auth system that merely works from one you'd trust with millions of users.

Stack: .NET, ASP.NET Core, C#, ASP.NET Core Identity, MediatR, FluentValidation, Dapper + stored procedures, SQL Server, JWT / refresh tokens, JWKS (ES256/RS256), HybridCache, OAuth 2.0 / OIDC

(sole-engineer architecture for a production marketplace — walk-through on request)

@misc{ammar2026autharchitecture,
  author = {Ammar, Md. Abu},
  title  = {Authentication Architecture — One Core, Two Edges, Every Session Revocable},
  year   = {2026},
  note   = {Engineering case study}
}