Passkin signs people in to other people's sites. A mistake here does not leak
one site's data; it lets someone be someone else, everywhere. Each rule below
has a test in apps/auth-broker/tests or packages/db/tests.
Who can get a code for whom
- A code is issued only to a registered redirect URI, compared exactly
(
oidc/clients.ts → redirectAllowed). Until the URI is known to be the site's, every error is a page on Passkin, never a redirect. - PKCE S256 is required for every client. A code is bound to the client, the redirect URI and the challenge, lives two minutes, and works once. A second redemption ends the session the first one created.
- Codes and refresh tokens are stored as SHA-256 hashes; a dump of the database (or of Redis, if one is used) hands out nothing usable.
Websites and apps
A project's applications share its users and keys; each is held to what its
platform can keep (oidc/clients.ts, tests/applications.test.ts).
- Each type may register only the addresses it can safely use: https for
websites (http only on loopback, and never in production); a
reverse-domain private scheme (
com.example.app:) or https for iOS and Android; loopback on any port, a private scheme or https for desktop.javascript:,data:,file:and the like are refused for every type, and a URL of the wrong kind never matches even if it was somehow stored. - An app on a device or a page cannot keep a secret, so a secret sent with
an
spa,ios,androidordesktopclient id is refused, not ignored. They are held to PKCE. - A code, a refresh token and a session belong to one application: a code issued to the iPhone app cannot be redeemed as the website, and its refresh token does not work for another client id. Deleting an application ends every session that came through it.
- Any app on a phone or a computer can claim a private scheme or listen on a
loopback port, so a code for one of those addresses is never handed out
without the person pressing Continue — even when they allowed the app
before, and
prompt=noneanswersinteraction_required(RFC 8252 §8.6). A Universal Link or App Link is https proven by the domain, and is answered like a website. - The id token's audience is the application; the access token's is the
project, so one backend accepts every application's tokens and reads
client_idto know which one called. - The page names who is asking: the host of a website, or «iOS app · com.example.shop» for an app.
Native sign-in
An app that signs in with the phone's Google sheet or Sign in with Apple
presents the provider's ID token (native.ts, tests/native.test.ts). With
no redirect URI to prove which app is talking, the token must:
- verify against Google's or Apple's published keys, with their issuer, and be at most ten minutes old;
- be addressed to the project's Google web client or to the app's own client,
and issued to the app's own client at the provider (Google's
azp, Apple'saud). Those client ids are registered per application and unique across Passkin — the first registration wins, so a token one customer's app received is never accepted for another's. On Passkin's shared Google app only Passkin's operators attach Google clients (from a developer's request naming the app's package and certificates), so no developer can register a client id that is not theirs; - carry a nonce Passkin issued for that very application, spent on first use whatever the outcome — a token copied from a log, or replayed, is refused.
Then the same path as the browser: unverified email refused, bans and the
temporary-address policy applied (join.ts is shared), and a secret from the
app refused.
Who the person is
- Identity comes from Google's
id_token(issuer, audience and nonce checked), from GitHub's verified emails (/user/emails, never the profile's public field), or from a code sent to the address. - A passport is found by the upstream account first, by verified email second. An unverified address never reaches anyone's passport.
- There is no simulated provider in production code. Tests inject a fake
upstream through
createBrokerApp({ upstreams }).
The passport session
- The cookie holds 32 random bytes; the database holds their hash.
__Host-prefixed,Secure,HttpOnly,SameSite=Laxin production. - A fresh sign-in replaces the session in that browser. (Previously the passport id lived in the cookie and overrode a new sign-in, so the next person on a shared computer was signed in as the last.)
- A sign-in transaction is bound to the browser that started it (a random browser id cookie), so a half-finished sign-in link cannot be completed by a victim and delivered to someone else.
The hosted pages
- No JavaScript at all; styles under a per-response CSP nonce;
frame-ancestors 'none'andX-Frame-Options: DENY(no clickjacked consent);Cache-Control: no-store;Referrer-Policy: same-origin. - Every form carries a per-transaction CSRF token, and posts are refused when
the browser reports another origin (
Origin,Sec-Fetch-Site). - Everything shown is escaped (
http/html.ts); colours and logos from a site's branding are validated before use. - Every page shows the domain that will receive the person, not only the name the site chose — or, for an app, that it is an app and its bundle id or scheme.
- CSP
form-actionalso governs where a form's redirect may go, so it names the one place this sign-in returns to: the site's origin, or the app's scheme.
Projects are separate
- A Passkin session in the browser is reused only for someone who has signed
in to that project before. Someone known from another developer's
project gets the ordinary sign-in page — no «Continue as», nothing that
says Passkin knows them — and
prompt=noneanswerslogin_required. - Passkin shows no consent page of its own; a site that wants one asks with
prompt=consent. - Deleting a person from the last project that had them deletes Passkin's record of them (email, sign-in methods, sessions), unless they are a developer with a workspace.
Tokens
- Each project signs with its own RS256 key, encrypted at rest under the master key; JWKS serves only public members.
- Refresh tokens rotate on every use. A rotated token presented again (after a ten-second grace for racing tabs) ends the whole session.
- Refresh stops at the site's lifetime or idle limit, when the site ends the
session, or when the site bans them.
/userinfochecks the session in the database, so a revocation is visible there at once.
Cross-origin
- Discovery and JWKS are public (
Access-Control-Allow-Origin: *). - Token, userinfo and revocation answer a browser only from the origins its websites and single-page apps list, and never with credentials. No endpoint reads the passport cookie cross-origin.
Abuse
- Rate limits (shared across instances, in Postgres or Redis): authorize per IP, token per IP, email codes per IP and per address, code guesses per IP and five per code.
- The client IP is read from the right of
X-Forwarded-For, pastTRUSTED_PROXY_HOPS; IPv6 is grouped by /64.
Operations
- Production refuses to start with a missing or public master key, a non-https
PASSKIN_URL, an embedded database, or the development sign-in. - Logs carry method, path and status, never the query string (codes, states).
- Unhandled errors return an id; the message stays in the log.
- Site actions through the Backend API, bans, deletions and detected refresh-token reuse are written to the audit log.