~6 min readgrounded in apps/web/lib/auth.ts · apps/web/app/api/auth/** · apps/web/app/api/{me,login,logout}/route.ts · apps/worker/src/users.ts · packages/contracts/src/auth.ts
Identity·
There are exactly three kinds of caller in a tiny-vercel deployment, and every route can tell them apart:
| Caller | Proof | Where it comes from |
|---|---|---|
| A user | the tiny_session cookie, or Authorization: Bearer <cli token> |
GitHub OAuth, a passkey, or the CLI/app consent flow |
| The app talking to the worker | x-internal-key: <INTERNAL_API_KEY> |
one shared secret, set on both deployables |
| Anyone | nothing | anonymous visitors, rate-limited by IP |
The worker never sees a browser. It trusts the internal key (compared in constant time, apps/worker/src/users.ts) and takes the user id the app already verified as a parameter. All identity logic lives in the app.
Getting a user: GitHub OAuth·
GET /api/auth redirects to GitHub with scopes read:user user:email. The route mints a nonce, stores it in an httpOnly tiny_oauth_state cookie (10-minute lifetime, SameSite=Lax so it survives the top-level redirect back), and packs it into state as <nonce>:<return_to>. On the callback (?code=…&state=…) the nonce must match the cookie — a forged callback that tries to log the victim into an attacker's account dead-ends, a genuine user whose nonce expired is simply bounced to a fresh login. return_to passes safeReturnPath: same-origin paths only, so //evil.com and /\evil.com fall back to /.
The code is exchanged, the GitHub profile is upserted into D1 through the worker (POST /user/upsert), and a session cookie is set. Variables: GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, AUTH_JWT_SECRET. The OAuth app's callback URL must be https://<your-app>/api/auth — see Deploy.
The session·
A session is a JWT (HS256 with AUTH_JWT_SECRET) carrying sub (the user's D1 id), login, name, avatar, in the cookie:
Thirty days. getSession(req) reads the cookie — anchored to a cookie boundary, so a stray x_tiny_session= cannot shadow it — or a bearer token, verifies it, and returns {sub, login, name, avatar} or null. GET /api/me returns the user, their tinys and their standing (a reputation score that can raise the free-tier allowance) — 401 when signed out. POST /api/logout clears the cookie.
Rotating AUTH_JWT_SECRET signs everyone out, browsers and devices alike. Nothing else breaks.
Passkeys·
Once a user has a session they can enroll a WebAuthn passkey and never touch GitHub again on that device.
GET /api/auth/webauthn/register→ registration options (challenge stashed in a signed httpOnly cookie);POSTverifies the attestation and stores the credential in D1 (/credential/add). Options:attestationType: 'none',residentKey: 'preferred',userVerification: 'preferred'.GET /api/auth/webauthn/login→ authentication options for discoverable credentials (usernameless);POSTverifies the assertion against the stored public key, updates the sign counter (/credential/signcount), and issues the same session cookie.
The relying party id is the request's Host (falling back to NEXT_PUBLIC_APP_URL's host) and the expected origin is the request's Origin; rpName is your NEXT_PUBLIC_SITE_NAME. Passkeys are bound to the domain you deploy on — moving domains means re-enrolling.
Devices and the CLI: a consent code, then a long token·
A laptop running tiny-vercel, or the phone app, cannot set a browser cookie. They get a bearer token instead, through a flow that keeps the click in the browser:
- The client generates a
statenonce and opens/auth/cli?port=<loopback port>&state=<nonce>(the app usesscheme=tinyappinstead of a port). - The consent page is session-gated — the user signs in if needed and clicks Approve. That click is the consent.
POST /api/auth/climints a 5-minute JWT withaud: tiny-cli-codecarrying the user claims and the nonce, and redirects tohttp://127.0.0.1:<port>/…?code=…&state=…(loopback and an integer port only) ortinyapp://auth?code=…&state=…(the scheme is an allowlist of exactly that one value, never a client-supplied URL).- The client calls
POST /api/auth/cli/token {code, state}. The state must match what was signed into the code. Back comes{ok, token, user, expires}: a 90-day JWT withaud: tiny-cliand ajti, so a future denylist can revoke CLI tokens without touching browser sessions. - Every session-gated
/api/*route accepts it asAuthorization: Bearer <token>—verifySessionignores the audience.
That token is what a device stores after enrollment, and what it uses to poll its relay mailbox. See Devices.
Owning a tiny, and private tinys·
Tinys belong to the user who created them (user_id in D1). Owner-only actions — modify_ai, set_price, customize_page, deleting — check that the session's sub matches. A tiny can also be private: reading it needs its key, sent as x-tiny-key on /api/chat or as key in POST /api/login {name, key}. That endpoint is deliberately rate-limited at the base IP allowance with no widening for signed-in callers, because the window is the brute-force budget against another owner's key.
Constants·
All of these are exported from packages/contracts/src/auth.ts, so a client in another language can copy them: SESSION_COOKIE = 'tiny_session', OAUTH_STATE_COOKIE = 'tiny_oauth_state', SESSION_TTL_S = 2592000, CLI_CODE_AUDIENCE = 'tiny-cli-code', CLI_CODE_TTL_S = 300, CLI_TOKEN_AUDIENCE = 'tiny-cli', CLI_TOKEN_TTL_S = 7776000, IOS_AUTH_SCHEME = 'tinyapp'.