# One sign-in screen, three owners of identity

Author: Chris Chabot
Canonical URL: https://chabot.dev/blog/one-sign-in-screen-three-owners-of-identity/
Published: 2026-11-03

How hosted authentication, a sign-in broker and application-local accounts each vouch for a different fact, with signed browser handoffs and each one's limits.

By the end of August 2026 the applications I was building had three separate ways of deciding who someone was. The platform's hosted authentication could perform the Google or GitHub ceremony in front of an application and hand it a subject the gateway had verified. A first-party sign-in broker could turn that verified subject into a short-lived assertion for one named web property, which would then issue a session of its own. And an application-local package could keep accounts, password hashes, sessions and CSRF tokens inside a single application, with no outside provider involved. All three can sit behind a screen that looks much the same, which makes it easy to talk about "the login" as though it were one system. Anyone deciding what a request may do needs a more precise question: which component is vouching for this caller, and what is it actually in a position to know?

## The ceremony and the cost of repeating it

The hosted layer owns the ceremony. It deals with the provider, verifies the result and names a subject, and it belongs to the platform's core.

The trouble came from every web property using it directly. Each one needed its own callbacks registered with Google and GitHub, so eight properties had grown into thirty-two console entries. In effect the provider consoles had become a hand-maintained list of every property in every environment.

So I designed the broker to be the only host the providers know about. It is itself an ordinary application behind managed auth, on a login host, which means the consoles carry two callbacks per environment instead of two per property, and adding a property comes down to a row in a registry and a redeploy. The broker never sees a provider token. It reads the subject the gateway verified, mints an assertion for one named property and redirects the browser there.

## The browser as courier

That redirect is where the design has to be careful, because the browser carries the result. Put a bare subject in a query string and anyone can sign in as anyone by editing a URL. The assertion therefore carries a MAC, which is what makes the browser a courier rather than an author.

Each signed field is there because of a specific misuse. The audience names the one application allowed to redeem the assertion, since otherwise an assertion minted for a low-value property could be replayed against a high-value one and every property would inherit the weakest one's exposure. The expiry has to be signed or an attacker simply rewrites it, and it is deliberately short, sixty seconds, because the thing only has to survive one redirect. The nonce makes each assertion distinct and gives the receiver something to record. The verifier checks the MAC before it considers anything else and takes the expected audience from its own configuration, never from the token. The signing key is derived under its own label, so an assertion MAC can't collide with a session MAC minted from the same operator secret even if both cover identical bytes.

The broker treats its other inputs with the same suspicion. Starting a sign-in checks authentication, then the client table, then the return path, in that order. An unknown client is refused and not defaulted, clients are registered by exact HTTPS origin, and the return path is treated as attacker-authored: one narrow shape is accepted and anything else falls back to the root.

When the browser arrives, the property redeems the assertion and mints its own session, a `__Host-` cookie that is MAC-verified and lasts 24 hours. From then on the broker is out of the picture, and the property has inherited a job. With this arrangement the gateway no longer refuses anonymous traffic at the edge, so the gate inside the application has to: it verifies the session, answers non-HTML requests with a 401 and returns a 503 if its signing secret isn't bound. That trade isn't reasonable for tenant applications, and the key arrangement points the same way. The key is symmetric and shared by the broker and every client, so any holder can mint for any audience and a compromised client can forge an assertion to another client. Asymmetric signing would remove that exposure; the SDK available for this design lacked the necessary primitive. Both conditions can be lived with across a set of first-party properties under one operator. Neither is something to ask of other people's applications.

## Where the implementation fell short of the design

Two details of the gate at the end of August show how much of this rests on the handoff itself. The contract for redeeming an assertion says that the seen-nonce check must atomically record and test, and that it runs before any session is issued. The gate read the store and then wrote to it, with no compare-and-swap, so two concurrent redemptions of one assertion could both find it unseen. One minute and one audience keep that window narrow, but "single-use" is not the accurate description of a narrow window, and making the word true takes an atomic operation in the store. The second was in the helper that published the gate's reply. If publishing failed, it returned the value that meant "proceed", so a caller could carry on into the application even though the gate's answer never went out. The 503 for a missing secret fails closed and this path did not, and a gate fails closed only if an error on the way out stops the request as well.

## The application that keeps its own accounts

The third boundary exists for a different reason. Some applications need to know who someone is inside their own domain. A helpdesk has customers and agents within tenants, and a record saying an agent did something is only as good as the application's grounds for using the word. The package I added for this is meant to be the smallest thing that makes an application's audit trail mean what it says, and it leaves out federation, passkeys, email delivery and account recovery on purpose.

Within that scope the choices are conventional and deliberate. Passwords go through PBKDF2 using the platform's crypto. A session token is 32 bytes of host entropy and only its SHA-256 is stored, so a leaked store yields no usable session. The session key carries the store's own TTL, so expiry is enforced by storage and doesn't depend on a sweeper that might not run. The browser receives a `__Host-` prefixed, `HttpOnly`, `Secure`, `SameSite=Strict` cookie and a separate script-readable CSRF cookie bound to that session, along the lines of the defenses in [OWASP's CSRF cheat sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html). Authority is a sealed type that only the authentication step can produce, after it has checked the session, the CSRF token and the role and before anything parses the request.

The helpdesk shows how the layers compose. It mounts the SSO gate at the front door and does its own work behind it. A browser presents a cookie, an API or agent caller presents a bearer token, and both resolve against the same stored digest, so a model connected over MCP is admitted on the same terms as the person at the console. Only the cookie path needs a CSRF check, because a cookie is the credential the browser attaches without being asked. The session then maps to a role, a session with no profile is not a caller at all, and a store failure comes back as an error and never as an anonymous visit. The gate can say that someone got through sign-in. Only the application can say that this person is an agent of this tenant, allowed to touch this ticket.

So which component owns identity? Each owns a part, and the arrangement holds up only where each stays modest about the rest. The hosted layer owns the proof that someone controls an account at a provider. The broker owns one narrow statement, addressed to one named property and good for one minute: this is the subject the gateway verified. The property owns its session after that, along with the job of turning anonymous traffic away and the question of what this person may do there. If it keeps its own accounts it owns the credentials as well, along with everything it chose not to build, account recovery included. The browser, in all of this, is still only the courier.
