AGENT VIEW This page as an agent reads it. Fetch it from /docs/security.md, or send Accept: text/markdown.

Open llms.txt
GET /docs/security · accept: text/markdown200 text/markdown; charset=utf-8 · vary: Accept
# Security and privacy notes

## What signo stores

Everything lives in one SQLite file, `signo.db`, in `DATA_DIR`.

| Table | What it holds | How long |
|---|---|---|
| `users` | a random ID (the `sub` apps receive), email, optional name, when the account was created | until the account is deleted |
| `passkeys` | for each passkey: its credential ID, public key, signature counter, transport hints, when it was added and last used | until removed or the account is deleted |
| `magic_links` | for each pending email link: a SHA-256 of its token (never the token), the email, the sign-in it belongs to, its expiry | until used, or 15 minutes |
| `oidc_store` | oidc-provider's records: sessions, consents, sign-ins in progress, authorization codes, access tokens and single-page apps' refresh tokens, plus the registered apps; apps added on the sites page also carry their owner's account ID | sessions 14 days, consents 1 year, sign-ins 1 hour, codes 1 minute, access tokens 10 minutes, refresh tokens 14 days at most; apps until removed, or until their owner's account is deleted |

Expired rows are deleted every 10 minutes. `keys.json` beside the database holds the ID-token signing key and the cookie-signing key (see Keys below).

signo stores no passwords. It writes no request logs, no IP addresses and no user agents, and pages load no third-party scripts, fonts or analytics. Rate-limit counters are the only place IP addresses appear; they live in memory and are dropped when their window ends. When `SMTP_URL` is not set, sign-in links are printed to the console instead of emailed, together with the address they are for; set it in production.

## Cookies

| Cookie | Purpose | Lifetime |
|---|---|---|
| `signo_session` | who is signed in | 14 days |
| `_interaction`, `_interaction_resume` | tie the browser to a sign-in in progress | only during a sign-in, at most 1 hour |

Each has a `.sig` companion that signs it, as oidc-provider recommends. All are HttpOnly and SameSite=Lax, and Secure over HTTPS. There are no other cookies.

## Signing in and recovery

- Accounts are created only after the person opens a sign-in link sent to their email. Apps therefore always receive `email_verified: true`, and nobody can create an account in someone else's name.
- Passkeys are the main way to sign in. They are WebAuthn credentials bound to signo's domain, so another site cannot use them.
- Email links work once, expire after 15 minutes, and finish the sign-in only in the browser that asked for them. Opening a link shows a "Continue" button and only that button uses the token, so email scanners that open links can't use it up.
- Recovery: someone who has lost their passkeys asks for an email link, signs in, and adds a passkey on their new device.

## Protocol settings

- Authorization code flow only, with PKCE (S256) required for every app.
- ID tokens are signed with RS256 and carry email and name, as Google's do. Access tokens last 10 minutes.
- Redirect URIs must use https; http is allowed only for localhost.
- Single-page apps get refresh tokens bound to a key their browser keeps (DPoP, RFC 9449), so a copied token is useless. They last at most 14 days from sign-in, end when the person signs out of signo in a browser where they used the app, and aren't replaced when used. A DPoP proof whose time is more than five minutes off gets a nonce from signo to sign instead, so a device with a wrong clock can still sign in.
- Revoking tokens never removes consent: when someone signs out, or an authorization code is used twice, the tokens go and the consent stays until the person withdraws it.
- Browsers may call the token endpoint only for single-page apps, which have no secret, and only from the origin of one of the app's callback URLs. Any site's page may call userinfo, from the same origins.
- Consent is remembered per account and app for up to a year. Signing out ends the session and revokes the tokens of every site used in it, in all of the person's browsers, but keeps the consent; deleting the account removes it.

## Sites added by account holders

- Any account can add up to 10 sites at `/account/sites`. Only the account that added a site can see it, give it a new secret or remove it.
- A site added as a single-page app gets no secret, since anyone can read its code. It signs in with PKCE, and its token requests must carry a DPoP proof.
- The client secret is shown once, when the site is added or given a new secret. signo stores it as given, because oidc-provider compares it on every token request. A new secret replaces the old one at once.
- Anyone can give a site any name, so the consent screen names the domain the person returns to before listing what the site receives.
- Callback URLs follow the same rules as apps added with the CLI: https only, except for localhost.

## Protections on the sign-in, consent and account pages

- Cross-site request forgery: a POST to these pages is refused unless the browser reports it as same-origin (`Sec-Fetch-Site`, or `Origin` on browsers without it).
- A Content-Security-Policy allows scripts only from signo itself and forbids other sites from framing the pages, which blocks clickjacking of the sign-in and consent screens.
- They are sent with `Cache-Control: no-store`.
- The sign-out confirmation is oidc-provider's own form, protected by its own CSRF token.

## Rate limits

| What | Limit |
|---|---|
| Email links to one address | 5 per 15 minutes |
| Email links from one IP address | 30 per 15 minutes |
| Sign-in attempts from one IP address | 60 per 15 minutes |
| Token requests from one IP address | 600 per minute |

Counters are kept per signo process and reset on restart. Behind a proxy, the IP address comes from `X-Forwarded-For` when `TRUST_PROXY` is set, or from the header named in `CLIENT_IP_HEADER` for proxies that use their own.

## Deleting an account

Deleting an account removes its row in `users`, its passkeys, its pending email links, all of its sessions, consents, codes and tokens, and the sites it added. SQLite's `secure_delete` setting overwrites the freed space in the database file. A recently deleted row can survive briefly in SQLite's write-ahead log (`signo.db-wal`) until the next checkpoint, and in any backups you keep. Apps the person signed in to keep whatever they saved at the time.

## Keys

The signing key and cookie key are created on first start and kept in `DATA_DIR/keys.json`, readable only by signo's user. Back the file up together with the database. If it is lost, everyone is signed out and apps fetch the new public key from `/jwks` on their next sign-in. v0.1 does not rotate keys automatically.

## Moving to a new domain

Passkeys belong to the domain signo runs on. If you move signo to a new domain, people sign in once with an email link and add a passkey again, and each app must be given the new issuer URL.