# signo docs > Passkey sign-in that websites use instead of "Sign in with Google". An OpenID Connect provider, open source under the AGPL-3.0. Source: https://usesigno.co.uk/docs/quickstart.md # Quickstart Add signo to a site that signs people in with Google today. It takes about five minutes and no new SDK: your auth library already speaks OpenID Connect, and so does signo. These steps use the public signo at https://usesigno.co.uk. On your own copy of signo, use its address instead; [Self-hosting](https://usesigno.co.uk/docs/self-hosting.md) shows how to run one. ## 1. Add your site Sign in at https://usesigno.co.uk/account/sites with a passkey or an email link, and add your site: the name people will see when they sign in, and the callback URL your auth library uses. signo then shows three values. | Setting | Value | |---|---| | Issuer | `https://usesigno.co.uk` | | Client ID | shown on your site's page | | Client secret | shown once, when you add the site | Copy the secret straight away. If you lose it, your site's page gives you a new one, and the old one stops working at once. ## 2. Swap three settings Put signo's values where your auth library has Google's. With Auth.js: ```ts providers: [ { id: "signo", name: "signo", type: "oidc", issuer: process.env.AUTH_SIGNO_ISSUER, clientId: process.env.AUTH_SIGNO_ID, clientSecret: process.env.AUTH_SIGNO_SECRET, }, ], ``` Each of these guides was tested against a running signo and shows the exact change: - [Auth.js (NextAuth)](https://usesigno.co.uk/docs/authjs.md) - [Better Auth](https://usesigno.co.uk/docs/better-auth.md) - [Supabase](https://usesigno.co.uk/docs/supabase.md) - [Any other OpenID Connect library](https://usesigno.co.uk/docs/openid-connect.md) - [A single-page app with no server, using signo.js](https://usesigno.co.uk/docs/single-page-apps.md) ## 3. Keep the people who signed up with Google signo sends each person's email with `email_verified: true`, because it only creates an account after the person opens a link sent to that address. Match existing users by that email. Better Auth and Supabase do it without extra settings. Auth.js needs `allowDangerousEmailAccountLinking: true` on the provider when it stores users in a database; the Auth.js guide explains why that is safe with signo. ## 4. Test a sign-in Sign in on your site. You see signo's sign-in page with your site's name. The first time, signo then shows what your site will receive and the domain you will return to. After you allow it, you are back on your site, signed in. --- Source: https://usesigno.co.uk/docs/how-it-works.md # How signo works signo is an OpenID Connect provider: the same kind of service as "Sign in with Google", run without Google. Your site sends people to signo, signo checks who they are, and sends them back with a signed statement of their email address. ## A sign-in, step by step 1. Someone presses "Sign in" on your site. Your auth library sends them to signo's `/auth` endpoint with your client ID, a PKCE challenge and the scopes it wants. 2. signo shows a sign-in page with your site's name. The person uses their passkey, or asks for an email link. 3. The first time a person signs in to your site, signo lists what your site will receive and the domain they will return to. They allow it or cancel. 4. signo sends them back to your callback URL with a one-time code. Your server exchanges the code for tokens, sending its client secret and the PKCE verifier. 5. The ID token is signed with signo's key (RS256, published at `/jwks`). It carries `sub`, `email`, `email_verified` and, if the person gave one, `name`. ## Accounts - An account is created only when someone opens a sign-in link sent to their email. Every email signo sends to sites is therefore verified, and nobody can create an account in someone else's name. - After that, people sign in with a passkey: their fingerprint, face or screen lock. A passkey only works on signo's own domain, so a lookalike site can't use it. - Someone who has lost their passkeys asks for an email link, signs in, and adds a passkey on the new device. - `sub` is a random ID for the account. Every site receives the same `sub` for the same person. ## What signo remembers - A signo session lasts 14 days. While it lasts, people go through other sites' sign-ins without signing in again. - Consent is remembered per person and site for up to a year, so the consent screen appears once. - Signing out of signo ends the signo session and revokes the tokens of the sites used in it, in all of the person's browsers, but keeps consent. A site with its own session keeps it; a single-page app using signo.js signs the person out the next time it checks with signo. ## What sites receive | Scope | Claims | |---|---| | `openid` | `sub` | | `email` | `email`, and `email_verified`, which is always `true` | | `profile` | `name`, if the person gave one | [Scopes and claims](https://usesigno.co.uk/docs/scopes-and-claims.md) has the details, and [Security model](https://usesigno.co.uk/docs/security.md) lists everything signo stores. --- Source: https://usesigno.co.uk/docs/authjs.md # Switch from Google to signo: Auth.js (NextAuth) Tested on 20 Oct 2026 with Auth.js (`@auth/core` 0.41.3, through `@auth/express` 0.12.3) against a running signo. A new person signed up and signed in, and a person who had signed up with Google kept their account. The examples use `login.example.com` for signo and `example.com` for your site. ## 1. Register your site with signo On the public signo, sign in at https://usesigno.co.uk/account/sites and add your site with the redirect URI below. The page shows the issuer (`https://usesigno.co.uk`), a client ID and a client secret. On your own signo, run this on its server instead: ``` docker compose exec signo signo clients add --name "Example" --redirect-uri https://example.com/api/auth/callback/signo ``` It prints the issuer, a client ID and a client secret. The redirect URI is Auth.js's callback for a provider whose `id` is `signo`: `/api/auth/callback/signo` in Next.js, or `/callback/signo` in other frameworks. ## 2. Replace the Google provider In `auth.ts`: ```diff import NextAuth from "next-auth" -import Google from "next-auth/providers/google" export const { handlers, auth, signIn, signOut } = NextAuth({ - providers: [Google], + providers: [ + { + id: "signo", + name: "signo", + type: "oidc", + issuer: process.env.AUTH_SIGNO_ISSUER, + clientId: process.env.AUTH_SIGNO_ID, + clientSecret: process.env.AUTH_SIGNO_SECRET, + }, + ], }) ``` In `.env`: ```diff -AUTH_GOOGLE_ID=... -AUTH_GOOGLE_SECRET=... +AUTH_SIGNO_ISSUER=https://login.example.com +AUTH_SIGNO_ID= +AUTH_SIGNO_SECRET= ``` If your code calls `signIn("google")`, change it to `signIn("signo")`. Auth.js's built-in sign-in page shows a "Sign in with signo" button without further changes. Nothing else changes. Auth.js uses PKCE by default, which signo requires. It reads the person's email and name from the ID token, and signo puts them there, as Google does. ## 3. Keep the people who signed up with Google If your site stores users through an Auth.js adapter (a database), a person who signed up with Google arrives from signo with a new identity. By default Auth.js refuses to attach it to the existing user with the same email and shows the error `OAuthAccountNotLinked`. In the test, that is what happened. Add one line to the signo provider: ```diff { id: "signo", ... clientSecret: process.env.AUTH_SIGNO_SECRET, + allowDangerousEmailAccountLinking: true, }, ``` With it, the person signs in to their existing account; in the test they landed on the same user ID as before. Auth.js calls the option "dangerous" because some providers send email addresses they never checked. signo only signs people in after they have opened a link sent to their address, so every email it sends is verified. Leave the option off for any provider you don't know does the same. Without an adapter, Auth.js keeps no user records, so there is nothing to link. If your own tables store data under the person's Google ID (`sub`), look those rows up by email the first time someone signs in with signo, because their signo `sub` is different. --- Source: https://usesigno.co.uk/docs/better-auth.md # Switch from Google to signo: Better Auth Tested on 20 Oct 2026 with Better Auth 1.7.12 against a running signo. A new person signed up and signed in, and a person who had signed up with Google kept their account, with no extra settings. The examples use `login.example.com` for signo and `example.com` for your site. ## 1. Register your site with signo On the public signo, sign in at https://usesigno.co.uk/account/sites and add your site with the redirect URI below. The page shows the issuer (`https://usesigno.co.uk`), a client ID and a client secret. On your own signo, run this on its server instead: ``` docker compose exec signo signo clients add --name "Example" --redirect-uri https://example.com/api/auth/callback/signo ``` It prints the issuer, a client ID and a client secret. Better Auth's callback is `/api/auth/callback/`. ## 2. Replace the Google provider On the server, swap the Google social provider for the Generic OAuth plugin: ```diff import { betterAuth } from "better-auth" +import { genericOAuth } from "better-auth/plugins" export const auth = betterAuth({ - socialProviders: { - google: { - clientId: process.env.GOOGLE_CLIENT_ID!, - clientSecret: process.env.GOOGLE_CLIENT_SECRET!, - }, - }, + plugins: [ + genericOAuth({ + config: [ + { + providerId: "signo", + discoveryUrl: `${process.env.SIGNO_ISSUER}/.well-known/openid-configuration`, + clientId: process.env.SIGNO_CLIENT_ID!, + clientSecret: process.env.SIGNO_CLIENT_SECRET!, + scopes: ["openid", "email", "profile"], + }, + ], + }), + ], }) ``` In `.env`: ```diff -GOOGLE_CLIENT_ID=... -GOOGLE_CLIENT_SECRET=... +SIGNO_ISSUER=https://login.example.com +SIGNO_CLIENT_ID= +SIGNO_CLIENT_SECRET= ``` In the browser, the sign-in call names the new provider: ```diff -await authClient.signIn.social({ provider: "google" }) +await authClient.signIn.social({ provider: "signo" }) ``` Better Auth 1.7 treats Generic OAuth providers like built-in social providers, uses PKCE by default and checks signo's ID tokens against its published keys. Older Better Auth versions use `authClient.signIn.oauth2({ providerId: "signo" })` and a different callback path; follow the Generic OAuth docs for your version. ## 3. Keep the people who signed up with Google Nothing to do. Better Auth attaches a sign-in to the existing user with the same email when the new sign-in's email is verified and the existing account's email is verified too. signo's emails are always verified, and accounts created through Google are marked verified, so these people keep their account. In the test, the person who signed up with Google landed on the same user ID. Accounts that were never verified, such as password sign-ups that skipped email confirmation, are not linked automatically; that is Better Auth's `requireLocalEmailVerified` default. --- Source: https://usesigno.co.uk/docs/supabase.md # Switch from Google to signo: Supabase Supabase Auth has supported custom OIDC providers since April 2026. Tested on 20 Oct 2026 against a running signo with self-hosted Supabase Auth v2.197.0, using its admin API. A new person signed up and signed in, and a person who already had an account kept it. The examples use `login.example.com` for signo and `example.com` for your site. ## Before you start Supabase only accepts an issuer it can reach on the public internet. The issuer must be an https URL, and it must not point to localhost or to a private network address. The public signo at usesigno.co.uk meets all three, and so does a signo set up with the Docker guide on a public domain. A signo you can only reach on your own machine or office network does not. ## 1. Register Supabase with signo On the public signo, sign in at https://usesigno.co.uk/account/sites and add Supabase as a site, with the redirect URI below. The page shows the issuer (`https://usesigno.co.uk`), a client ID and a client secret. On your own signo, run this on its server instead: ``` docker compose exec signo signo clients add --name "Example" --redirect-uri https://.supabase.co/auth/v1/callback ``` It prints the issuer, a client ID and a client secret. For self-hosted Supabase, the redirect URI is `https:///auth/v1/callback`. The dashboard also shows the exact callback URL when you create the provider. ## 2. Add signo as a custom provider In the dashboard: Authentication → Sign In / Providers → Custom Providers → New Provider. Choose the OIDC option, which finds signo's endpoints from its issuer URL, and enter: | Field | Value | |---|---| | Identifier | `custom:signo` | | Name | `signo` | | Issuer | `https://login.example.com` | | Client ID, Client Secret | from step 1 | | Scopes | `openid email profile` | Or with the admin API from a server, using your secret key: ```js await supabase.auth.admin.customProviders.createProvider({ provider_type: 'oidc', identifier: 'custom:signo', name: 'signo', client_id: process.env.SIGNO_CLIENT_ID, client_secret: process.env.SIGNO_CLIENT_SECRET, issuer: 'https://login.example.com', scopes: ['openid', 'email', 'profile'], }) ``` The test created the provider with these fields through the HTTP endpoint behind that call, `POST /auth/v1/admin/custom-providers`. Supabase turned PKCE on for it (`pkce_enabled: true`), which signo requires. The dashboard form takes the same values. A Free plan project can have up to three custom providers. ## 3. Change the sign-in call ```diff -await supabase.auth.signInWithOAuth({ provider: 'google' }) +await supabase.auth.signInWithOAuth({ provider: 'custom:signo' }) ``` The rest of your code is unchanged. In the test, the Supabase session carried the person's email, `email_verified: true`, their name, and `custom:signo` as the provider. ## 4. Keep your existing users Nothing to do. Supabase links a sign-in to the existing user with the same verified email, and signo only sends verified emails. In the test, a user who already existed signed in with signo and kept the same user ID; the account then listed both its original identity and `custom:signo`. --- Source: https://usesigno.co.uk/docs/openid-connect.md # Switch from Google to signo: any OpenID Connect library signo works with any library that supports OpenID Connect discovery and the authorization code flow. signo's own browser tests sign in to a demo site built with openid-client 6.8.8, a certified OpenID Connect library, and the code below comes from that demo. The examples use `login.example.com` for signo and `example.com` for your site. On the public signo, the issuer is `https://usesigno.co.uk`. ## 1. Register your site with signo On the public signo, sign in at https://usesigno.co.uk/account/sites and add your site with its callback URL. On your own signo, run `signo clients add` on its server, as in [Self-hosting](https://usesigno.co.uk/docs/self-hosting.md). Either way you get an issuer, a client ID and a client secret. ## 2. What your library needs | Setting | Value | |---|---| | Discovery | `https://login.example.com/.well-known/openid-configuration` | | Flow | authorization code (`response_type=code`) | | PKCE | required, with `S256` | | Scopes | `openid email profile` | | Client authentication | HTTP Basic with the client secret (`client_secret_basic`) | | Callback URLs | https, except on `localhost` | The ID token carries `email`, `email_verified` and `name`, as Google's does, so code that reads them from Google's ID token keeps working. The userinfo endpoint at `/me` returns the same claims. ## 3. Example with openid-client ```ts import * as oidc from 'openid-client'; const config = await oidc.discovery( new URL('https://login.example.com'), process.env.SIGNO_CLIENT_ID!, undefined, oidc.ClientSecretBasic(process.env.SIGNO_CLIENT_SECRET!), ); // Sign-in: send the person to signo. const verifier = oidc.randomPKCECodeVerifier(); const state = oidc.randomState(); const nonce = oidc.randomNonce(); const url = oidc.buildAuthorizationUrl(config, { redirect_uri: 'https://example.com/callback', scope: 'openid email profile', code_challenge: await oidc.calculatePKCECodeChallenge(verifier), code_challenge_method: 'S256', state, nonce, }); // Callback: exchange the code. Keep verifier, state and nonce in the person's session in between; // currentUrl is the full URL signo sent the person back to. const tokens = await oidc.authorizationCodeGrant(config, currentUrl, { pkceCodeVerifier: verifier, expectedState: state, expectedNonce: nonce, idTokenExpected: true, }); const { sub, email, name } = tokens.claims(); ``` ## 4. Signing out To sign the person out of signo as well as your site, send them to the end-session endpoint with their ID token. The return URL must be one of the site's sign-out return URLs. ```ts const logout = oidc.buildEndSessionUrl(config, { id_token_hint: idToken, post_logout_redirect_uri: 'https://example.com/', }); ``` ## 5. Keep the people who signed up with Google The person's signo `sub` differs from their Google one. Look existing users up by email the first time they sign in with signo. That is safe, because signo's emails are always verified. --- Source: https://usesigno.co.uk/docs/single-page-apps.md # Switch from Google to signo: single-page apps (signo.js) A single-page app runs in the browser, often with no server of its own, so it can't keep a client secret. signo.js signs people in to it with the authorization code flow and PKCE, which is what OAuth 2.0 for Browser-Based Apps recommends, and needs no secret. It is one file, served by signo. signo's browser tests sign in to a demo single-page app with it. The examples use the public signo at `https://usesigno.co.uk`. On your own copy of signo, use its address instead. ## 1. Add your site as a single-page app Sign in at https://usesigno.co.uk/account/sites, add your site, and tick "It's a single-page app with no server of its own". As the callback URL, use the exact address of the page that loads signo.js, such as `https://example.com/`. To let people sign out of signo from your app as well, add the same address as a sign-out return URL. The site gets a client ID and no secret. On your own signo, register it with `--public` instead: ``` docker compose exec signo signo clients add --name "Example" --redirect-uri https://example.com/ --public ``` ## 2. Add signo.js to the page ```html ``` - `signIn()` sends the person to signo. - `handleRedirect()` belongs on every page load. It finishes a sign-in when signo sends the person back, putting back the address the page had when `signIn()` was called, without signo's code. Otherwise it returns whoever is signed in, checking with signo first once their ID token has expired, or `null`. It throws a `SignoError` only when a sign-in that signo just sent back failed or was cancelled. Calls made while one is running share its result, so React's StrictMode can call it twice. - `user()` returns the signed-in person as last checked, or `null`. - `signOut()` forgets the person in your app and deletes the key their refresh token was bound to. Give `createSigno` a `postLogoutRedirectUri` to send them to signo's sign-out page as well; whatever they choose there, your app's tokens for them then end in all their browsers. If your page used Google Identity Services, remove its script and button; signo.js replaces both. If your page sends a Content-Security-Policy, allow `https://usesigno.co.uk` in `script-src` and `connect-src`. With a bundler such as Vite, which doesn't import from URLs, save signo.js into your project and import it from there. ## 3. What signo.js checks - The response belongs to the sign-in your page started (`state`) and comes from signo (`iss`, RFC 9207). - The code only works together with the PKCE verifier your page kept, so a code seen by anyone else is useless. - The ID token is for your client ID and this sign-in (`nonce`), and hasn't expired. It comes straight from signo's token endpoint over TLS, which OpenID Connect accepts in place of checking its signature. - Every request to signo's token endpoint carries a proof signed with this browser's key (DPoP), so tokens issued to the page only work from it. signo answers browser requests to its token endpoint only from the origins of your callback URLs. ## 4. How long people stay signed in Without `remember`, signo.js keeps the person and their ID token in `sessionStorage`, with no refresh token. They stay signed in while the tab is open, until the ID token expires an hour after sign-in. Signing in again then takes one click while their signo session lasts. With `remember: true`, signo.js keeps them in `localStorage` with a refresh token, so they stay signed in after the tab closes. Once the ID token has expired, `handleRedirect()` asks signo again and gets a new one. If signo refuses, the person is signed out of your app; rate limits, outages and network failures leave them signed in, to try again on the next page load. The refresh token: - is bound to a key the browser keeps in IndexedDB (DPoP, RFC 9449). No script can read the key's private half, so a copy of the token is useless anywhere else, and `signOut()` deletes the key. Being useless when copied, it isn't replaced when used; - lasts at most 14 days from sign-in, and ends sooner if the person signs out of signo in a browser where they used your app, which ends it in all their browsers. A sign-in in progress waits in `localStorage` for up to an hour, because email links usually open in a new tab and the sign-in finishes there. Any script on your page can use what signo.js keeps while the page is open, so keep third-party scripts off pages that use it, as you would for any sign-in. ## What signo.js doesn't do yet - **Tokens for your own API.** The access token works only at signo's userinfo endpoint. Tokens for your API are planned, with resource indicators (RFC 8707). ## Keep the people who signed up with Google If your app keeps users anywhere, match them by verified email, as in the [Auth.js guide](https://usesigno.co.uk/docs/authjs.md). signo's emails are always verified. --- Source: https://usesigno.co.uk/docs/agents.md # Agent access signo signs in people today. Agent access is planned: an AI agent will be able to act for a person on a site, within limits the person approved with their passkey, and the person will see what it did and be able to stop it at any time. Nothing on this page beyond "What works today" is live yet. ## What works today - Coding agents can add signo to an app. Every docs page is Markdown at its own address plus `.md`, and `/llms.txt` on the same site lists them all. See [Markdown and llms.txt](https://usesigno.co.uk/docs/markdown.md). - The person adds the site on the sites page of the signo they use (https://usesigno.co.uk/account/sites on the public one), and puts the client ID and secret in the app's environment themselves. ## Rules for agents, today - signo's sign-in, consent and account pages are for people. Don't fill them in for someone. - Never ask a person for a sign-in link or a code from their email, or to paste a client secret into chat. ## What is planned | Feature | Standard | |---|---| | Agents identify themselves with a client ID that is an HTTPS URL | OAuth Client ID Metadata Document | | Agents register themselves | RFC 7591, dynamic client registration | | Tokens that work for one site or MCP server only | RFC 8707, resource indicators | | Tokens bound to the agent's own key, useless if copied | RFC 9449, DPoP | | Sign-in from a terminal: the agent shows a code, the person approves it | RFC 8628, device authorization | | Limits inside the request, such as "bookings up to £300 each" | RFC 9396, rich authorization requests | | An MCP server for signo itself | Model Context Protocol | One rule will hold for all of it: granting access always needs the person's passkey, and stopping access never does. --- Source: https://usesigno.co.uk/docs/markdown.md # Markdown and llms.txt Every page on signo's website has a version written for agents: the same content as Markdown, without navigation or styling. | You want | Ask for | |---|---| | A page as Markdown | its path plus `.md`, such as `/docs/quickstart.md` or `/trust.md`; the home page is `/index.md` | | The same, from the page's own address | the page with `Accept: text/markdown` | | An index of the docs | `/llms.txt` | | All the docs in one file | `/llms-full.txt` | | To see what agents see, in a browser | the **Agent** button at the top of any page, which adds `?view=agent` | Responses that depend on `Accept` carry `Vary: Accept`. HTML pages point to their Markdown with `` and a matching `Link` header. Links inside the Markdown are absolute, and links between docs point at their Markdown versions, so an agent can follow them without parsing HTML. ## A prompt for your coding agent Paste this into any coding agent that can read a web page: > Replace "Sign in with Google" in this app with signo. Read https://usesigno.co.uk/llms.txt, then follow the guide for this app's auth library. I'll add the site at https://usesigno.co.uk/account/sites and put the client ID and secret in .env myself; don't ask me to paste the secret into chat. The home page's "Copy prompt" button copies the same prompt, with the address of the copy of signo you are on. --- Source: https://usesigno.co.uk/docs/endpoints.md # Endpoints Paths are relative to the issuer, which is `https://usesigno.co.uk` on the public signo. Libraries should read the OpenID Connect endpoints from discovery rather than hard-code them. ## OpenID Connect | Path | What it is | |---|---| | `/.well-known/openid-configuration` | Discovery: the endpoints below and what signo supports | | `/auth` | Authorization endpoint, where your site sends people to sign in | | `/token` | Token endpoint, which exchanges a code for tokens | | `/me` | Userinfo endpoint | | `/jwks` | The public keys that verify ID tokens | | `/session/end` | End-session endpoint, for signing out | | `/request` | Pushed authorization requests (RFC 9126). oidc-provider enables it; signo's tests don't cover it yet | ## For developers | Path | What it is | |---|---| | `/signo.js` | The browser library for single-page apps, as a JavaScript module any site may load | ## For people | Path | What it is | |---|---| | `/account` | The account page: name, passkeys, sign out, delete the account | | `/account/sites` | Add and manage sites | | `/privacy` | What this copy stores and who receives it | | `/trust` | Standards, practices and how to report a security problem | ## For agents | Path | What it is | |---|---| | `/llms.txt` | An index of the docs, as Markdown | | `/llms-full.txt` | All the docs in one file | | any page + `.md` | That page as Markdown; `/index.md` for the home page | | `/.well-known/security.txt` | The security contact, when the operator of the copy has set one | ## 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 | --- Source: https://usesigno.co.uk/docs/scopes-and-claims.md # Scopes and claims Sites ask for scopes, and signo returns the claims that belong to them, after the person has allowed it. | Scope | Claims | Notes | |---|---|---| | `openid` | `sub` | Required. A random ID for the account, the same for every site. | | `email` | `email`, `email_verified` | `email_verified` is always `true`: accounts exist only after their email link is used. | | `profile` | `name` | Only when the person has given a name. | signo puts these claims in the ID token, as Google does, and returns them from the userinfo endpoint (`/me`) too. Libraries such as Auth.js read them from the ID token. The ID token also carries the standard claims your library checks, such as `iss`, `aud` and `exp`. ## What the person sees The consent screen names the domain the person will return to, then lists what the site receives: their email, their name if the site asked for `profile`, and an ID for their signo account. They allow it once per site; signo remembers that for up to a year. ## Not supported - Scopes other than `openid`, `email` and `profile` - The `claims` request parameter - Pairwise subject identifiers: every site receives the same `sub` for a person --- Source: https://usesigno.co.uk/docs/tokens.md # Tokens and lifetimes | What | Lifetime | |---|---| | Authorization code | 1 minute, single use | | Access token | 10 minutes | | ID token | 1 hour | | A sign-in in progress | 1 hour | | Email sign-in link | 15 minutes, single use, only in the browser that asked for it | | signo session | 14 days | | A single-page app's refresh token | 14 days from sign-in, bound to its browser's key | | Consent for a site | 1 year | ## ID tokens ID tokens are JWTs signed with RS256. The public key is at `/jwks`; libraries fetch it through discovery. v0.1 doesn't rotate the signing key on a schedule. If the key changes, libraries pick up the new one from `/jwks` on the next sign-in. ## Access tokens Access tokens are opaque strings that work at the userinfo endpoint (`/me`). Sites that only need to know who signed in can ignore them and read the ID token. ## Refresh tokens: single-page apps only Sites with a server get no refresh tokens. When a site's own session ends, it sends the person through sign-in again; while their signo session lasts, that takes no action from them. Single-page apps get one with every sign-in, without asking for `offline_access`, so signo.js can keep a person signed in. Their tokens must be bound to a key the browser keeps (DPoP, RFC 9449), so a refresh token copied out of the browser is useless. Each lasts at most 14 days from sign-in and ends when the person signs out of signo in a browser where they used the app. Being useless when copied, it isn't replaced when used. ## Signing out The end-session endpoint (`/session/end`) ends the signo session and revokes the tokens of every site used in it, in all of the person's browsers, because consent, and the tokens issued under it, belong to the person and the site and so are shared by all their browsers. Consent stays, so the next sign-in skips the consent screen. Deleting the account removes everything, consents included. --- Source: https://usesigno.co.uk/docs/security.md # 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. --- Source: https://usesigno.co.uk/docs/self-hosting.md # Self-hosting Run your own copy of signo on a server with Docker. It takes about five minutes. You need a server with Docker, a domain for signo (for example `login.example.com`) whose DNS points at that server, ports 80 and 443 open, and an SMTP account to send sign-in emails. ## 1. Get the code and fill in three settings ``` git clone https://codeberg.org/egglabs/signo.git && cd signo cp .env.example .env # set SIGNO_DOMAIN, SMTP_URL and MAIL_FROM ``` ## 2. Start it ``` docker compose up -d ``` Caddy gets the HTTPS certificate for your domain. Open `https://login.example.com` and sign up, to check that the email arrives. ## 3. Register your site ``` docker compose exec signo signo clients add --name "Example" --redirect-uri https://example.com/api/auth/callback/signo ``` This prints the issuer, a client ID and a client secret. You can also sign in at `https://login.example.com/account/sites` and add the site there. ## 4. Point your site at it Put those three values where your site has its Google ones. The guides show the exact changes for [Auth.js](https://usesigno.co.uk/docs/authjs.md), [Better Auth](https://usesigno.co.uk/docs/better-auth.md), [Supabase](https://usesigno.co.uk/docs/supabase.md) and [any other OpenID Connect library](https://usesigno.co.uk/docs/openid-connect.md). ## Managing apps ``` docker compose exec signo signo clients list docker compose exec signo signo clients remove ``` Changes apply without a restart. Redirect URIs must use https; http is allowed only for localhost. ## Settings | Variable | Meaning | Default | |---|---|---| | `ISSUER` | signo's public https URL (compose sets it from `SIGNO_DOMAIN`) | `http://localhost:4100` | | `PORT` | port to listen on | `4100` | | `DATA_DIR` | where the database and keys live | `./data` (`/data` in Docker) | | `SMTP_URL` | SMTP server for sign-in emails, e.g. `smtps://user:pass@smtp.example.com:465` | unset: links print in the console | | `MAIL_FROM` | sender of sign-in emails | `signo ` | | `TRUST_PROXY` | set behind a reverse proxy (compose sets `1`) | unset | | `CLIENT_IP_HEADER` | the header your proxy puts the visitor's IP address in, if it doesn't use `X-Forwarded-For` (alwaysdata uses `x-real-ip`); only for a proxy that always overwrites it | unset | | `OPERATOR` | who runs this copy, shown on the privacy page and in the footer of every page | unset | | `CONTACT` | email address for privacy and security reports; with it set, signo also serves `/.well-known/security.txt` | unset | ## Say where your copy runs The trust and privacy pages have a section called "Where this copy runs". Fill it in by writing a short Markdown file at `DATA_DIR/hosting.md`: who hosts the server and where, who sends the sign-in emails, what the host logs, and how long backups are kept. signo reads it at startup and shows it as written. For example: ``` - **Hosting and database:** Example Hosting, Amsterdam, the Netherlands - **Sign-in emails:** sent through Example Mail - **Backups:** kept for 14 days ``` ## Backups Back up the `signo-data` volume, or `DATA_DIR` outside Docker. It holds the database and the signing keys; without the keys, everyone is signed out and sites fetch the new public key on their next sign-in. ## 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 site must be given the new issuer URL.