Search for a command to run...

My blog's admin area is guarded twice. The frontend signs me in with a session. The API checks a token. Neither system knows about the other, and a small exchange route stands between them. This post is the real setup β the two systems, the two journeys, and the trade-offs I accepted.
I did not set out to build two auth systems. The frontend is Next.js, and it needed a session: a logged-in admin browsing pages, a dialog, a sign-out button. The API is ASP.NET Core, and it needed stateless tokens: every request proves itself, with no server-side session to look up. Rewriting either side to match the other would have cost more than bridging them. So I bridged them.
The frontend uses better-auth. It keeps its own tables β user, session, account, verification β in the same PostgreSQL database as the blog's content. A session lasts seven days and renews when it gets older than a day. Sign-in happens through the frontend's own dialog, with GitHub as a social option. After sign-in, the browser holds a session cookie, and the app knows who I am on every page.
A session is good for a UI. It survives refreshes, it works across pages, and revoking it is one row in a table. It is bad for an API: the API would have to understand the frontend's session store, and every request would drag the frontend's concerns into the backend.
The API uses JWT Bearer authentication. Every protected request must carry a token in the Authorization header, and the API checks four things before it trusts it: the signature, the issuer, the audience, and the expiry. If any one of them fails, the request stops there. Admin endpoints add one more gate: an AdminOnly policy that demands the Admin role.
A token is good for an API. It is self-contained: the API verifies it from the signature alone, with no lookup and no shared session store. It is bad for a UI: it expires fast, it cannot be revoked by deleting a row, and storing it in the browser is a decision you have to make deliberately.
Here is what happens when I sign in. I sign in through the frontend dialog. better-auth creates a session and the browser holds the cookie. On load, the app's session provider reads that session β and immediately calls a small Next.js route called exchange-token.
That route runs on the server, not in the browser. It reads the session server-side, then mints a JWT for the .NET API: my user id, email, and name, signed with a secret both sides share, valid for fifteen minutes. The browser stores that token and uses it for API calls. The signing secret never reaches the browser.
Fifteen minutes is deliberate. The session lives for days; the API token lives for minutes. If a token leaks, its window is small. When it expires, the frontend exchanges the session for a fresh one β the session is the long-lived proof, the token is the short-lived pass.
Every API call from the browser goes through one helper. It reads the token from storage and attaches it as a Bearer header. The request then travels the API pipeline in order: authentication first, authorization second. Authentication answers βis this token real?β Authorization answers βis this identity allowed here?β
A missing or bad token means 401: prove who you are again. A valid token without the required role means 403: I know who you are, and the answer is still no. Keeping those two apart matters β one is a login problem, the other is a permission problem, and debugging them as if they were the same wastes hours.
One system would be simpler to explain. Two systems let each side keep the auth that fits it: sessions for pages, tokens for APIs. The bridge is a single route, and neither side had to change how it works.
The costs are real. There are two sessions to reason about, not one. The lifetimes disagree β seven days against fifteen minutes β so expiry bugs show up in two places. The token sits in browser storage, which means any script running on the page could read it. And every new developer on this codebase has to learn the bridge before the auth story makes sense. I accepted those costs because the alternative was rewriting working auth on one side or the other.
The signature, against the shared secret β no signature, no trust.
The issuer and the audience β the token must be mine, and meant for my API.
The expiry β enforced, not advisory, and short.
The secret lives on the server only β the browser never sees it.
Admin endpoints additionally demand the role β authentication is not authorization.
None of this is exotic. It is the default checklist, applied without shortcuts. The one decision that is not default is the split itself β and the split is what makes the rest easy to reason about.
Two doors, one bridge. The frontend guards the pages with a session. The API guards the data with a token. The exchange route translates between them, and the shared secret is the only thing they have in common. If you run a split frontend and backend, you probably do not need one auth system. You need two systems that agree on exactly one thing.
My blog's admin area is guarded twice: better-auth sessions in the browser, JWT Bearer on the API, and one small exchange route between them. This is the real setup, with the journeys and the trade-offs.
Ever wondered what happens when you type a URL into your browser? Join me as we trace a request from your client to my server, and explore the enterprise-grade architecture powering my new platform.
I've shipped CQRS with MediatR in two real systems β and deliberately left it out of a third. The pattern isn't the point; knowing where its boundary sits is.