Start here
Security model
Plainly, so you can decide what to trust it with.
- The box is the boundary. Tiffin runs as root on its own machine and manages system services. Apps run in containers. Do not share a box with people you don't trust.
- App network. App ports are reachable only from the box itself; the edge is the way
in. Apps can't open connections to port 25 on other servers (the box's firewall refuses
them): their mail goes through the box (
SMTP_URL), which sends it with the mail service you connect, so an app can't hurt the box's sending reputation behind your back. Each project may only send as its own addresses (<project>@<box domain>or its verified sending domain), never as the box or another project. - Apps share the box's network, not each other's traffic. Apps listen on loopback ports of the host, so an app could take a port another app left free (asleep, or restarting). Every connection the box opens to an app (requests, health checks, queue jobs and crons) is checked first: the kernel names the cgroup of the process that answers, and it must be in the app's project. A port another project took gets no request, and a wake starts the app on a new port. App containers run without raw sockets (they can't read loopback traffic), without ports below 1024 (they can't join the edge's sockets on 80 and 443) and can't gain privileges through setuid programs. Inside the box, the edge reaches the dashboard and API, and its own switchboard (which passes requests to apps), on Unix sockets no app can reach or take over. What this does not cover yet is in Limits.
- HTTPS everywhere. A server with a public address gets certificates from a public CA
(Let's Encrypt). Locally the box has its own certificate authority, created on the box
and never shared.
tiffin trustadds it to your Mac's keychain. - Your config is code from your repository, which a pull request can change.
tiffin planandapplyruntiffin.config.tson your computer the way a push runs it on the box: no file, network or environment access (process.envis empty) and imports only from its git repository, never~/.tiffinwhere your owner tokens are. - Git pushes.
tiffin git-remote --addinstalls a credential helper that hands a box's token only to that box's address, never to another remote. - SSH to servers. Tiffin pins each server's SSH host key the first time it connects
and refuses a different one after. For a server you bring (
--provider ssh) it also honours the keys in your own~/.ssh/known_hosts; see limits. - Builds run your code in containers. BuildKit runs the build steps; a static site
builds in a container capped in memory and tasks. Your env reaches a build only inside
those containers (as BuildKit secrets, build args or container env), never the
environment of the box's own tools (Railpack, BuildKit's client, nerdctl), which run
as root: a variable such as
PATH,DOCKER_CONFIGorLD_PRELOADcan't reconfigure them. Each app's build caches are its own. What a build writes is checked before the box uses it (links must stay inside, no FIFOs or devices) and read with size caps. Known gap: Railpack plans on the host (see limits). - Only HTTPS leaves a local box, and only to
127.0.0.1:8443on your Mac. Postgres, Valkey and the rest are not reachable from outside the VM. - API keys are random 200-bit secrets; only their SHA-256 is stored. Each reaches some projects (or all) with full or read access, and works for 30 days, 90 days (the default), a year, or until revoked. A key is its own credential: the dashboard session that made it can end or expire and the key keeps working, like a GitHub or Vercel personal access token. Only a key with full access to all projects (or an owner or admin person) can create, list or revoke keys. See API keys and Creating API keys in the dashboard.
- Agents and destructive changes. By default, your agent can do what you can.
Claude Code asks you before anything destructive (Tiffin marks those tools
destructive); Tiffin records everything in History and can undo it. Give agents that
run unattended, or that should only touch one project, a narrower key: read access,
or only that project. Outside its reach a key gets
403 forbidden; there is no approval step to get around it. A key for some projects also gets no box-wide reports (the disk breakdown, the box's resources, backups and restore drills), and the observe overview, alerts and alert rules show it only its own projects' containers and alerts. - Secrets (env vars) are encrypted with the box's own age key and are never shown after you set them.
- App sign-in tokens. The access, refresh and ID tokens that Google, GitHub and the
other providers return when someone signs in to an app are encrypted (XChaCha20-Poly1305)
with the project's auth key before they reach the app's database. The key stays in the
auth engine's config, out of the database and the app's environment. Only the
signed-in person gets them back: an app's API key (
tfk_) is refused (API_KEY_NOT_ALLOWED) on/get-access-token,/refresh-tokenand/account-info. Each project's auth secret comes only from that config: the engine ignoresBETTER_AUTH_SECRET(S),AUTH_SECRET,BETTER_AUTH_TRUSTED_ORIGINSandBETTER_AUTH_URLin its environment. See Sign-in providers. - App sign-in is kept apart per app. The engine's cookies are
__Host-(only the app's own host can set them, so another app on the box can't plant a session), each project has its own rate-limit counters, one project's sign-in settings or provider answers can't stop the engine (a project with invalid settings is left out; a provider's answer is capped at 1 MB and 15 s), and@shiptiffin/sdk/authchecks a session against its own app's project (TIFFIN_AUTH_HOST) whatever host a request names. Nothing in the auth tables works as a credential: reset and magic-link tokens are stored hashed, one-time codes encrypted. - Mail content needs full access. Read-only access to a project shows each message's sender, recipients and delivery, not what it says: mail carries reset links and codes.
- Dashboard sign-in is a one-time link (
tiffin login, an invite, or one emailed on request), a passkey, or Google or GitHub (see below). Each gives a 12-hour session with exactly that person's role, in an HttpOnly, Secure, SameSite=Strict cookie named__Host-tiffin_session(browsers only take it from the dashboard's own host, so an app on a sibling host can't plant one). Signing in is refused from other sites. Passkey sign-in needs user verification (Face ID, fingerprint or PIN), uses a single-use challenge that expires after 2 minutes (the box keeps nothing per challenge until a passkey signs it, so floods of requests can't crowd anyone out), refuses people who were removed and passkeys whose signature counter goes backwards (a sign of a copied key), is limited to 10 attempts a minute per address, and is in the audit log. - Who may sign in as whom. Only the owner (the owner token or an owner's session)
makes a sign-in link for the owner. An API key acts for nobody, so it gets no
tiffin loginlink. An invite or admin's link (7 days) signs the person in with their own role, and works while whoever made it is still an owner or admin (a key: while it works), even after the session that sent it signs out or expires. A link a session makes for itself works while that session is open. - Client addresses. Apps share the server's network, so the API only believes the
address the edge forwards when the request carries the edge's key (made fresh at each
start, sent only to the dashboard): an app calling the API directly counts as
127.0.0.1, and can't pick its address for rate limits or the audit log. - Sign-in links by email. With a mail service connected, the login page offers Email me a sign-in link. The answer is the same whether or not the address belongs to anyone, and the lookup and the mail happen after it. Each link works once, for 15 minutes, and asking again cancels the previous one. Requests are limited to 5 per 15 minutes per client address and 3 an hour per email address, and refused from other sites. A link only exists if its email left the box through the relay: one that would wait in the box's dev inbox (which owners and admins can read) is never made, or is cancelled at once, because an emailed link counts as proof that you read that inbox. Box mail history (Settings › Email) shows such an email's sender, time and delivery only, never its text or link. Changing someone's address cancels every unspent link of theirs (emailed or invite), and a link is only made while the address it goes to is still theirs.
- Google and GitHub sign-in only signs in people already on the box, matched the first time by an email the provider vouches for, then by the linked provider account. It never makes an account. See Signing in with Google or GitHub.
- New sign-in notices. When someone with an email address signs in from a browser the
box hasn't seen them use, it emails them (browser, time, address, how). Their first
sign-in (the invite) and later sign-ins from the same browser are quiet. A random ID in
an HttpOnly cookie (
tiffin_device) is all the box keeps about a browser. The email's button, Review sign-ins, opens the page below. - Sign-ins and signing out. Settings › Sign-ins lists where you're signed in, with Sign out on each and Sign out everywhere else. See Seeing and ending sign-ins.
- Console history stays in the tab. What you type in the SQL and KV consoles (which can
hold a password or an
AUTH) is kept for the up arrow in that browser tab only, never in the browser's lasting storage, and is wiped when you sign out or your session ends. - Every change and security event is logged (changes with the key or person that made them, keys created and revoked, sign-ins, secrets).
- Known gaps: a person or agent with shell access to your Mac can read your local
owner token in
~/.tiffin. Backups stay on the box unless you set an off-box destination (tiffin backups offsite set); keep its passphrase off the box.
Seeing and ending sign-ins
Settings › Sign-ins (also in your menu, bottom left) shows:
- Where you're signed in: each open session's browser and system ("Safari on iPhone"), the address it signed in from and its country (looked up on the box, when the analytics country database is there), how it signed in (sign-in link, emailed link, passkey, Google or GitHub), when, and when it was last active (to the minute). This browser marks yours. Sign out ends one; Sign out everywhere else ends all but this one.
- Recent sign-ins: every sign-in of the last 30 days, and whether it is still signed in, was signed out or expired.
- Browsers this box knows: up to 20 browsers you signed in from. Signing in from one of these sends no new sign-in email.
A session that is signed out is refused on its very next request. API keys made while
signed in there keep working: revoke them on Settings › API keys. Other people's
sessions, even ones signed in with a link this session sent, stay. Ending a session here
(or on People) also cancels the sign-in links it made that nobody used yet, such as
invites; signing out of your own browser doesn't. A session is not an API key:
DELETE /v1/tokens/{id} refuses one.
Owners and admins can do the same for anyone: People › (their role menu) › End sessions… lists where that person is signed in, with Sign out on each and Sign out everywhere. They keep their access and can sign in again; to take it away, remove them. Only the owner can end the owner's sessions; admins can still see them.
Agents and scripts use API keys, not sessions, so they aren't listed here: see
API keys. Every sign-in is in the audit log (session.link, session.passkey,
session.oauth), and so is every sign-out from this page (session.end with the session,
session.end_others with the person, both with who did it).
For scripts and agents: GET /v1/sessions (?person=usr_… for someone else,
&history=true for the last 30 days), DELETE /v1/sessions/{id},
POST /v1/sessions/end-others (?person=usr_…) and GET /v1/sessions/browsers; in the
CLI, tiffin sessions list|end|end-others|browsers. Sessions belong to people, so an API
key must name the person, and only a key with full access to all projects may.
Sudo mode: confirm it is you
A few things outlive the dashboard session that does them, so a stolen session must not be able to do them quietly:
- Creating an API key that lasts longer than a day, or has full access (and so any admin key). A read-only key for a day doesn't ask.
- Adding a passkey. A passkey signs in as you for good, and it would pass every later "confirm it's you", so a stolen session must not be able to add its own.
- Changing an email address (yours, or as an owner or admin, someone else's; only the owner changes the owner's). Emailed sign-in links go there, and they count as a strong sign-in.
Each needs a strong sign-in in the last 10 minutes: a passkey, Google, GitHub or a link you
asked to be emailed, or, for the owner, their own tiffin login (a link made with the
owner token for the owner). That token can already add passkeys and keys without asking,
so this grants nothing new, and it lets the owner of a box with no mail relay and no
Google or GitHub keys add a first passkey. A one-time link someone else made (an invite,
an admin's link, a link the owner token makes for someone else) is not one, and neither
is a link a session makes for itself, the owner's included. Otherwise the dashboard asks
you to confirm with one of your own passkeys (someone else's doesn't count), or to sign
in again (Sign in again goes to the login page and back); either gives you 10
minutes. If you have no passkey yet, you add your first one after signing in with
Google, GitHub or an emailed link (the owner: or a fresh tiffin login). Settings ›
Sign-ins shows the owner's terminal sign-ins as tiffin login.
The API answers 403 reauth_required (with a hint) until then. The dashboard confirms with
POST /v1/session/confirm/options, then navigator.credentials.get(), then
POST /v1/session/confirm with {"credential": ...}; the session then repeats the call.
Keys and the owner token are not sessions: they never confirm, and the owner's CLI token
may add the owner's passkeys without it. Removing a passkey doesn't ask, but is emailed.
API keys can't change email addresses. A key isn't a session, so it can't confirm,
and an admin key that could repoint someone's address (the owner's included) could then
take over their emailed sign-in. PUT /v1/people/{id}/email with an API key is refused
(403 forbidden) when the address would change. People change addresses in the
dashboard; the owner token (tiffin people email) still changes anyone's.
An emailed sign-in link only counts when the mail left the box (see Sign-in links by
email above): one that would wait in the dev inbox is never made.
Creating API keys in the dashboard
A key outlives the session that made it. Three things keep a stolen session from minting one quietly:
- Confirm it's you (sudo mode), as above.
- An email for every key. The person who made it gets a New API key email: the key's name, its projects and access, when it expires, when, and the browser, address and country it came from, with a Review API keys button.
- The audit log records each key (
token.create, with the person) and each confirmation (session.confirm).
Removing someone, or lowering their role, still revokes every key they made (and keys those keys made).
Keys and the owner token are not sessions: they never confirm. A key made by another key (an agent delegating) never outlives the key that made it, and revoking a key revokes the keys it made.
For scripts: POST /v1/tokens with "expiresInDays": 1, 30, 90 (the default when left
out) or 365, or 0 for never. In a session that hasn't confirmed, a key that needs it is
refused with 403 reauth_required.
Adding and removing passkeys
- Confirm it's you (sudo mode) before adding one, from a
dashboard session: both
POST /v1/passkeys/registerandPOST /v1/passkeyscheck it. - An email for every change. The person gets a New passkey email when one is added (its name, when, and the browser, address and country it came from, with a Review passkeys button; "Wasn't you?" says to remove it and sign out everywhere else), and a Passkey removed email when one is removed.
- The audit log records
passkey.addandpasskey.delete, with the person and the passkey's name.
Signing in with Google or GitHub
When an owner sets the box-wide Google or GitHub keys (Box settings › Sign-in providers),
the login page shows Sign in with Google or Sign in with GitHub to the box's
people: owners, admins and members. It uses the same OAuth app and the same callback URL
as the apps' sign-in (https://<dashboard host>/api/auth/callback/<provider>), so there
is nothing more to register. Remove the keys and the button goes.
- Who gets in. The first time, the box asks the provider for the account's email and
signs in the active person on the box with that address (compared without case). For
Google that is the ID token's
email, only whenemail_verifiedis true and Google is authoritative for it: a Gmail address, or a Google Workspace account (hd). A Google account registered with any other address stays "verified" after that mailbox changes hands, so those don't match (sign in with an email link instead). For GitHub it is any verified address from/user/emails(the primary one is tried first); unverified addresses don't count. Nobody matches: That Google account isn't on this box. Ask an owner to invite you. Removed people never match. The box never makes an account, so invite someone (with their email) before they can sign in this way. - Linked accounts. That first sign-in links the provider account (Google's
sub, GitHub's user id) to the person. From then on that account signs them in, whatever address it shows, and no other account of that provider can, even one showing their verified address (linked): a provider's "verified" can outlive someone's hold on an address. To link a different account, remove the person and invite them again. - The session is the same as a passkey's or a link's: 12 hours, that person's role,
the same cookie, a new sign-in notice from a browser the box hasn't seen them use, and
session.oauthin the audit log (refusals aresession.oauth_refused, with why). - The flow is the authorization code flow with PKCE (S256) and a random state; Google
also gets an OpenID Connect nonce and
prompt=select_account, GitHuballow_signup=false. The state, PKCE verifier, nonce and where to go next ride in a 10-minute__Host-tiffin_oauthcookie (HttpOnly, Secure, SameSite=Lax), HMAC-signed with a key the box makes when it starts. On the way back the box checks the signature, the age, the provider and the state (constant-time), and spends the state: each works once, and only in the browser that started it. The ID token comes straight from Google's token endpoint over TLS, so its issuer, audience, expiry and nonce are checked, not its signature (as Google's OpenID Connect guide allows). The provider's token is used for that one request and never kept. Where to go next is always a path on the dashboard. - Limits. 10 starts and 10 returns a minute per client address; starts are refused from other sites.
For the dashboard: GET /v1/session/oauth lists the providers with keys set;
POST /v1/session/oauth/{google|github}?next=/path sets the state cookie and returns
{url} to open. The provider sends the browser back to the callback, which sets the
session and opens next, or goes to /login?reason=<provider>:<why> (unknown,
unverified, linked, expired, denied, failed, busy, off).
Signing in with a passkey
Set up a device once in Sign in with Touch ID / Face ID (your menu). From then on, choose Sign in with Touch ID on the login page: no username, no link. It is a passkey kept on that device, and every person can set up their own. Adding one needs a recent strong sign-in (sudo mode): confirm with a passkey you already have, or, for your first, sign in with Google, GitHub or an emailed link first. You are emailed about every passkey added or removed.
The dashboard names it the way your device does: Touch ID / Face ID on a Mac, iPhone or iPad, Windows Hello on Windows, fingerprint or face on Android, and a passkey anywhere else.
Passkeys added before passkey sign-in existed were not required to be discoverable (stored on the device so the browser can offer them without a username). Most phone and laptop passkeys are anyway; if yours isn't offered at the login page, remove it and add it again.
For the dashboard: POST /v1/session/passkey/options returns options for
navigator.credentials.get() (byte fields base64url, no allowCredentials); send
the result to POST /v1/session/passkey as {"credential": ...}. It sets the session
cookie and returns {person, name, role, expiresAt}. Failures are 401 unauthenticated
with a hint (expired or replayed prompt, unknown passkey, removed person) or
429 rate_limited with Retry-After.
Something wrong or unclear? Edit it on GitHub.