# 9in documentation 9in is a hosted login service and OpenID Connect provider (OAuth 2.1) running on Cloudflare. Apps redirect users to a hosted sign-in page on the tenant's domain and get standard tokens back, so any certified OpenID Connect library works; the 9in SDKs make it shorter. Facts to get right when integrating: - Each tenant has an issuer: https://.9in.io (production), https://-sandbox.9in.io (sandbox: any password works, one-time codes are 000000), or a custom domain such as https://login.example.com. Everything is discoverable at /.well-known/openid-configuration. - Browser and mobile apps are public clients: authorization code + PKCE (S256) only, no client secret. Server apps are confidential clients. Services use client_credentials with an API audience. - Access tokens are JWTs (typ at+jwt, RFC 9068) for the API named by the `audience` parameter, or opaque (9in_at_…) when configured; verify them with @9in-io/sdk/server (verifyAccessToken or createTokenVerifier) or any JWT library against /.well-known/jwks.json, checking iss, aud, exp and the token's own alg. - The Management API lives on the tenant host at /api/v1 and takes a client_credentials token with management:* scopes. - Webhooks and the adapter protocol are signed with the header 9in-signature: t=,v1=. # 9in documentation > Hosted login, OAuth 2.1 and OpenID Connect for your apps: what 9in is, how it works and where to start. Source: https://9in.io/docs/ 9in signs your users in. Your app sends people to a sign-in page that 9in hosts on your domain; they sign in with a password, a one-time code, Google, their company's identity provider or a single-use login link; and your app gets standard OpenID Connect tokens back. Your API checks those tokens. That's the whole integration. ```text Your app ──redirect──▶ https://acme.9in.io/oauth/authorize ──▶ hosted sign-in page ▲ │ └────────── code ◀───────────── redirect back to your app ◀────────┘ │ └──▶ POST /oauth/token → ID token (who signed in) + access token (for your API) ``` Because 9in is a standard OpenID Connect provider (OAuth 2.1), **any certified OIDC library works**. The 9in SDKs make the common cases shorter. ## Start here - [Quickstart](https://9in.io/docs/quickstart/): sign users in to a web app and call your API, in five minutes. - [Concepts](https://9in.io/docs/concepts/): tenants, environments, applications, APIs and connections. - [Build with AI](https://9in.io/docs/build-with-ai/): give your AI assistant everything it needs, in one link. ## Pick your app type | You are building | Read | Flow | |---|---|---| | A React, Vue, Svelte or plain JS app | [Single-page apps](https://9in.io/docs/single-page-apps/) | Authorization code + PKCE in the browser | | Next.js, Express, Rails, Django, any server | [Server-rendered apps](https://9in.io/docs/server-apps/) | Authorization code with a client secret | | PHP or Laravel | [PHP and Laravel](https://9in.io/docs/php-laravel/) | Authorization code with a client secret | | iOS, Android, React Native, Flutter, desktop | [Mobile and desktop apps](https://9in.io/docs/native-apps/) | Authorization code + PKCE in the system browser | | A service, cron job or script | [Machine to machine](https://9in.io/docs/machine-to-machine/) | Client credentials | | A TV app or a CLI | [TVs, CLIs and devices](https://9in.io/docs/devices/) | Device authorization (RFC 8628) | | An API that receives tokens | [Protecting your API](https://9in.io/docs/protecting-apis/) | Verify JWTs or introspect | ## What else is in the box - **Sign-in methods:** passwords, passwordless codes by email or SMS, authenticator-app two-factor, 14 social providers, enterprise SSO with any OIDC provider, and single-use login links. - **Your users, your way:** users live in 9in, in [your own database](https://9in.io/docs/own-database/), or move over from Auth0, Firebase, Cognito, Supabase, Clerk, Keycloak or Okta with their passwords ([importing users](https://9in.io/docs/importing-users/)). - **Your brand:** [themes](https://9in.io/docs/themes/) you edit by clicking the preview, every text in every language, [custom domains](https://9in.io/docs/custom-domains/) and branded [emails](https://9in.io/docs/emails-and-texts/). - **Operations:** [webhooks](https://9in.io/docs/webhooks/), the [Management API](https://9in.io/docs/reference/management-api/), live token revocation, an activity log, and a sandbox for every tenant. ## Machine-readable docs Every page is also plain Markdown (add `.md` to the URL, or use **Copy page as Markdown** at the top). [/llms.txt](https://9in.io/llms.txt) indexes all pages for language models, [/llms-full.txt](https://9in.io/llms-full.txt) has all of them in one file, [/openapi.json](https://9in.io/openapi.json) describes the Management API, and [/schemas/](https://9in.io/schemas/webhook-event.json) has JSON Schemas for webhooks, the adapter protocol and theme manifests. --- # Quickstart > Add sign-in to a web app in five minutes with the JavaScript SDK, then call your API with the user's token. Source: https://9in.io/docs/quickstart/ This walks through signing users in to a browser app with `@9in-io/sdk`, then calling your own API with their access token. For a server-rendered app, follow [Server-rendered apps](https://9in.io/docs/server-apps/) instead; the console steps are the same. ## 1. Create a tenant and an application 1. Sign in to the [console](https://app.9in.io) and create a tenant, say `acme`. You get two issuers: - production: `https://acme.9in.io` - sandbox: `https://acme-sandbox.9in.io` (separate users; any password works and one-time codes are always `000000`) 2. Go to **Applications → Create application → Single-Page App** and set: - **Allowed callback URLs:** `http://localhost:5173/callback` - **Allowed logout URLs:** `http://localhost:5173/` - **Allowed web origins:** `http://localhost:5173` (the browser calls the token endpoint directly, so its origin must be allowed for CORS) 3. Copy the **Client ID** (`app_…`). Single-page apps have no client secret. ## 2. Install the SDK ```bash npm install @9in-io/sdk ``` ## 3. Sign in and out ```ts import { NineAuth } from "@9in-io/sdk"; export const auth = new NineAuth({ issuer: "https://acme-sandbox.9in.io", // the sandbox while you develop clientId: "app_7f3k2m9q", redirectUri: `${location.origin}/callback`, }); // A "Sign in" button document.querySelector("#login")!.addEventListener("click", () => auth.login()); // A "Create account" button opens the sign-up form directly document.querySelector("#signup")!.addEventListener("click", () => auth.signup()); // On /callback: finish signing in, then go where the user was heading if (location.pathname === "/callback") { const { user, returnTo } = await auth.handleRedirect(); console.log(`Signed in as ${user.email}`); history.replaceState(null, "", returnTo ?? "/"); } // Anywhere if (auth.isAuthenticated()) { const user = await auth.getUser(); // claims from the ID token: sub, email, name, picture… } // Sign out of your app and of 9in document.querySelector("#logout")!.addEventListener("click", () => auth.logout({ returnTo: location.origin + "/" })); ``` `login()` redirects to the hosted sign-in page with authorization code + PKCE. `handleRedirect()` checks `state`, exchanges the code and stores the tokens; by default they are kept in `localStorage` and refreshed automatically (refresh tokens rotate on every use). Open your app, click **Sign in**, and sign in to the sandbox with any email and any password. ## 4. Call your API Tell 9in about your API, so access tokens can be issued for it: 1. In the console, go to **APIs & Scopes → Create API**. Set the identifier to your API's URL, e.g. `https://api.acme.com`, and add a scope such as `orders:read`. 2. Request tokens for it in the browser: ```ts const auth = new NineAuth({ issuer: "https://acme-sandbox.9in.io", clientId: "app_7f3k2m9q", redirectUri: `${location.origin}/callback`, audience: "https://api.acme.com", scope: "openid profile email offline_access orders:read", }); const res = await auth.fetch("https://api.acme.com/orders"); // adds Authorization: Bearer ``` 3. Verify the token in your API (Node, Workers, Deno or Bun): ```ts import { verifyAccessToken } from "@9in-io/sdk/server"; export async function handle(request: Request) { const bearer = request.headers.get("authorization")?.replace(/^Bearer /i, ""); const claims = await verifyAccessToken(bearer, { issuer: "https://acme-sandbox.9in.io", audience: "https://api.acme.com", scope: "orders:read", }); // throws NineError (invalid_token, insufficient_scope) otherwise return Response.json({ orders: await listOrders(claims.sub) }); } ``` `verifyAccessToken` checks the signature against your tenant's public keys (cached), the issuer, audience, expiry and scopes. To also notice revoked tokens within seconds, see [Protecting your API](https://9in.io/docs/protecting-apis/). ## 5. Go to production - Add your real URLs to the application (callback, logout and web origin). - Switch the issuer to `https://acme.9in.io`, or to your [custom domain](https://9in.io/docs/custom-domains/). - Users, sessions and activity in production are separate from the sandbox. ## Next - Turn on more [sign-in methods](https://9in.io/docs/sign-in-methods/), [social login](https://9in.io/docs/social-login/) or [enterprise SSO](https://9in.io/docs/enterprise-sso/). - Make the pages look like your product with [themes](https://9in.io/docs/themes/). - Get notified about sign-ups with [webhooks](https://9in.io/docs/webhooks/). --- # Concepts > Tenants, environments, applications, APIs, scopes, connections and sessions, and how they fit together. Source: https://9in.io/docs/concepts/ ## Tenants A **tenant** is one isolated login service: its own users, applications, APIs, keys, themes, translations and settings. Most companies have one tenant per product. Each tenant has an **issuer**, the base URL of everything: | Issuer | What it is | |---|---| | `https://acme.9in.io` | Production | | `https://acme-sandbox.9in.io` | Sandbox | | `https://login.acme.com` | Production on a [custom domain](https://9in.io/docs/custom-domains/) | The issuer is the `iss` claim of every token and the prefix of every endpoint (`/oauth/token`, `/.well-known/openid-configuration`, `/api/v1/users`). Your apps should be configured with the issuer and discover the rest. ## Environments Every tenant has two **environments** with the same configuration but separate data: - **production**: real users. - **sandbox**: for development and tests. Users, sessions and activity are separate. Any password signs in (a user is created on the fly), one-time codes are always `000000`, and nothing is emailed or texted. Sandbox users never count towards your plan. Configuration (applications, APIs, branding, themes) is shared, so what you test in the sandbox is what runs in production. ## Applications An **application** is anything users sign in to, or anything that calls your APIs: an OAuth client with a client ID (`app_…`). Its kind decides the flow: | Kind | Client type | Grants | |---|---|---| | Single-page app | Public (no secret) | Authorization code + PKCE, refresh token | | Regular web app | Confidential (secret) | Authorization code (+ PKCE), refresh token | | Native / mobile | Public | Authorization code + PKCE, refresh token | | Machine-to-machine | Confidential | Client credentials | | TV & device | Public | Device code, refresh token | **First-party** applications (your own, the default) skip the consent screen. Third-party applications show it, listing the scopes they ask for. ## APIs and scopes An **API** is a resource server you protect with 9in, identified by an **audience** such as `https://api.acme.com`. When an app asks for a token with `audience=https://api.acme.com`, the access token is issued for that API (`aud` claim) and can only carry that API's **scopes** (such as `orders:read`). An API can also choose its own token format (JWT or opaque), signing key and lifetime; see [Tokens and claims](https://9in.io/docs/reference/tokens/). The OpenID Connect scopes `openid`, `profile`, `email`, `phone` and `offline_access` (a refresh token) are always available. ## Connections A **connection** is where identities come from: - **User database:** users stored in 9in (the default), or in [your own database](https://9in.io/docs/own-database/) through the adapter protocol. - **Social:** Google, GitHub, Microsoft, Apple and 10 more ([social login](https://9in.io/docs/social-login/)). - **Enterprise:** your business customers' identity providers ([enterprise SSO](https://9in.io/docs/enterprise-sso/)). A user can have several identities (a password, Google and their company login) linked to one account. ## Users A **user** has an id (`usr_…`, or the id kept from an [import](https://9in.io/docs/importing-users/)) that becomes the `sub` claim and never changes, identifiers (email, phone, username), a profile, your own metadata, and custom profile fields. See [Users and profiles](https://9in.io/docs/users/). ## Sessions and tokens When someone signs in, 9in starts a **session** on its domain (single sign-on: the next app they open doesn't ask again) and issues tokens to your app: - **ID token:** a JWT that says who signed in, for your app only (`aud` is your client ID). - **Access token:** for your API (`aud` is the API identifier), JWT or opaque. - **Refresh token:** with the `offline_access` scope; rotated on every use. Signing out, blocking a user or revoking a session or token takes effect at once on 9in; your API sees it as fast as its [verification mode](https://9in.io/docs/protecting-apis/) allows. ## Hosted pages Sign-in, sign-up, password recovery, one-time codes, two-factor, consent, the device page, errors and the user's account page are all served by 9in on the tenant's domain, in the visitor's language, with your [theme](https://9in.io/docs/themes/). Your app never handles passwords. --- # Build with AI > Everything an AI coding assistant needs to integrate 9in correctly: context files, specs and a prompt to start from. Source: https://9in.io/docs/build-with-ai/ AI coding assistants integrate 9in well when they read the right context first. Everything in these docs is available as plain text built for that. ## Give your assistant the docs | Link | What it is | Use it when | |---|---|---| | [`https://9in.io/llms.txt`](https://9in.io/llms.txt) | An index of every page with a one-line summary, plus the facts most often gotten wrong | The assistant can browse: it reads the index, then the pages it needs | | [`https://9in.io/llms-full.txt`](https://9in.io/llms-full.txt) | All pages in one Markdown file | You paste or attach context once | | `https://9in.io/docs/.md` | One page in Markdown (or **Copy page as Markdown** at the top of any page) | You want a focused context, such as one guide | | [`https://9in.io/openapi.json`](https://9in.io/openapi.json) | The Management API as OpenAPI 3.1 | Generating API clients or calls | | [`https://9in.io/schemas/…`](https://9in.io/schemas/webhook-event.json) | JSON Schemas: webhook events, adapter protocol, theme manifest | Typing webhook handlers and adapters | | `/.well-known/openid-configuration` | Your tenant's live OpenID Connect metadata | Configuring any OIDC library | The TypeScript SDK ships its own type definitions, so editors and assistants see every option of `@9in-io/sdk` as they type. ## A prompt to start from Fill in the brackets and paste this into your assistant: ```text Add sign-in with 9in to this [Next.js / Express / Laravel / React / iOS] app. Read https://9in.io/llms.txt first, then the guide for this kind of app and https://9in.io/docs/reference/tokens.md. Our tenant: - Issuer (sandbox while we develop): https://[tenant]-sandbox.9in.io - Application client ID: [app_…] (client secret in the env var NINE_CLIENT_SECRET, if any) - Callback URL: [http://localhost:3000/callback] - Our API's audience: [https://api.example.com], scopes: [orders:read] Requirements: - Use the official 9in SDK for this stack if there is one, otherwise a certified OpenID Connect library. Use authorization code + PKCE; never the implicit flow. - Read configuration from environment variables (NINE_ISSUER, NINE_CLIENT_ID, …). - Verify access tokens in the API: signature via the issuer's JWKS, iss, aud, exp, scopes. - Add sign-in, sign-out, a protected page and one protected API route, and tell me which URLs to add to the application in the 9in console. ``` ## Facts assistants get wrong Give these to an assistant that is guessing, or check its work against them: - **There is no client secret in browsers or mobile apps.** Single-page and native apps are public clients: authorization code + PKCE (S256). Never put a client secret in front-end code. - **The issuer has no path.** It is `https://.9in.io` (or a custom domain), not `…/oauth` or `…/v1`. Endpoints come from `/.well-known/openid-configuration`. - **Access tokens are for APIs, ID tokens are for the app.** Send the access token to your API; don't verify ID tokens there. Request access tokens for your API with `audience=`; without it, the access token's `aud` is your client ID. - **Access tokens are JWTs with `typ: at+jwt`**, signed with the algorithm of the tenant's key (RS256 by default, but ES256, EdDSA or HMAC are possible). Don't hard-code RS256; take the algorithm from the key. Some APIs use opaque tokens (`9in_at_…`), which must be introspected. - **Allowed web origins** must include a single-page app's origin, or the browser's token request fails with a CORS error. - **The Management API is on the tenant host:** `https://.9in.io/api/v1/…`, with a client-credentials token that has `management:*` scopes. There is no API key. - **User fields differ between input and output** in the Management API: you send `emailVerified`, `phone` and `phoneVerified`; you get back `email_verified`, `phone_number` and `phone_number_verified`. - **Webhooks and adapter calls are signed** with `9in-signature: t=,v1=` where the HMAC-SHA256 covers `t + "." + METHOD + "." + path + "." + rawBody`. Verify against the raw body, not re-serialized JSON. Event ids repeat on retries: process each once. - **Sandbox and production share configuration but not users.** `-sandbox.9in.io` accepts any password and the code `000000`; production users never appear there. --- # Single-page apps > Sign users in from React, Vue, Svelte or plain JavaScript with authorization code + PKCE, and call your API. Source: https://9in.io/docs/single-page-apps/ A single-page app runs entirely in the browser, so it cannot keep a secret. It signs users in with the **authorization code flow and PKCE**, exchanges the code from the browser, and keeps the tokens in the browser. `@9in-io/sdk` does all of it; any certified OIDC library (oidc-client-ts, angular-auth-oidc-client…) works too. ## Set up the application In the console, **Applications → Create application → Single-Page App**, then on its **Settings**: | Setting | Example | Why | |---|---|---| | Allowed callback URLs | `https://app.acme.com/callback` | Where 9in may send the code. Exact match. | | Allowed logout URLs | `https://app.acme.com/` | Where 9in may send users after sign-out (`post_logout_redirect_uri`). | | Allowed web origins | `https://app.acme.com` | The browser calls `/oauth/token` itself; without the origin the request fails with a CORS error. | Add `http://localhost:5173/callback` and `http://localhost:5173` too while you develop (plain http is allowed for localhost). ## Sign in ```ts import { NineAuth } from "@9in-io/sdk"; export const auth = new NineAuth({ issuer: import.meta.env.VITE_NINE_ISSUER, // https://acme.9in.io clientId: import.meta.env.VITE_NINE_CLIENT_ID, // app_… redirectUri: `${location.origin}/callback`, // scope: "openid profile email offline_access" (default) // audience: "https://api.acme.com" access tokens for your API }); await auth.login({ returnTo: location.pathname }); // or auth.signup() for the sign-up form ``` `login()` accepts `prompt` (`"login"` to force re-authentication, `"none"` to check silently, `"consent"`, `"select_account"`), `loginHint` (pre-fills the email), `screen: "signup"`, `returnTo` and extra `params`. ## Handle the callback ```ts // on /callback const { user, returnTo } = await auth.handleRedirect(); router.replace(returnTo ?? "/"); ``` `handleRedirect()` checks `state` and the issuer, exchanges the code with the PKCE verifier, stores the tokens and removes `code` from the address bar. If the user cancelled or something failed it throws a `NineError` with the OAuth error (for example `access_denied`). ## Use the session ```ts auth.isAuthenticated(); // true with a usable access token or a refresh token await auth.getUser(); // ID token claims: sub, email, email_verified, name, picture… await auth.fetchUserInfo(); // fresh claims from /oauth/userinfo await auth.getAccessToken(); // refreshed (and rotated) when it expires within a minute await auth.fetch("/api/orders"); // fetch() with Authorization: Bearer … await auth.logout({ returnTo: location.origin + "/" }); // revokes the refresh token, ends the 9in session ``` Tokens are kept in `localStorage` by default. Pass `storage` (anything with `getItem`, `setItem` and `removeItem`) to keep them elsewhere, for example in memory only. ## React ```tsx import { createContext, useContext, useEffect, useState } from "react"; import type { User } from "@9in-io/sdk"; import { auth } from "./auth"; const UserContext = createContext(undefined); export function AuthProvider({ children }: { children: React.ReactNode }) { const [user, setUser] = useState(undefined); useEffect(() => { (async () => { if (location.pathname === "/callback") { const { returnTo } = await auth.handleRedirect(); history.replaceState(null, "", returnTo ?? "/"); } setUser(auth.isAuthenticated() ? await auth.getUser() : null); })().catch(() => setUser(null)); }, []); return {children}; } export const useUser = () => useContext(UserContext); // undefined while loading, null when signed out ``` ## Call your API Ask for tokens for your API with `audience` and its scopes, then send them with `auth.fetch()` (or `Authorization: Bearer ${await auth.getAccessToken()}`): ```ts const auth = new NineAuth({ issuer, clientId, redirectUri, audience: "https://api.acme.com", scope: "openid profile email offline_access orders:read", }); ``` Your API verifies them as described in [Protecting your API](https://9in.io/docs/protecting-apis/). ## Good to know - **Single sign-on:** a user signed in to 9in from another app isn't asked again; `login({ prompt: "login" })` forces it. - **Refresh tokens rotate:** each refresh returns a new refresh token. Reusing an old one revokes the whole family, which signs the user out everywhere that family was used, so don't share tokens between tabs by copying them; the SDK coordinates refreshes for you. - **Sign-out everywhere:** `logout()` ends the 9in session too, so other apps using the same session are signed out on their next check. --- # Server-rendered apps > Sign users in from Next.js, Express, Hono or any back end with a confidential client and a server session. Source: https://9in.io/docs/server-apps/ A server-rendered app (Next.js, Express, Hono, Rails, Django, Go…) can keep a secret, so it is a **confidential client**: it exchanges the code on the server with its client secret, keeps the tokens in the server-side session, and the browser only ever holds a session cookie. ## Set up the application **Applications → Create application → Regular Web App**, then: - **Allowed callback URLs:** `https://app.acme.com/callback` (and `http://localhost:3000/callback` for development). - **Allowed logout URLs:** `https://app.acme.com/`. - Copy the **Client ID**, and the **Client secret** from the **Credentials** tab into your server's environment as `NINE_CLIENT_SECRET`. Rotate it there when needed; the previous secret keeps working until the next rotation. ## Node.js with openid-client [openid-client](https://github.com/panva/openid-client) is a certified OpenID Connect client for Node, Bun, Deno and Workers. ```ts import * as client from "openid-client"; import express from "express"; import session from "express-session"; const config = await client.discovery(new URL(process.env.NINE_ISSUER!), process.env.NINE_CLIENT_ID!, process.env.NINE_CLIENT_SECRET!); const redirect_uri = "https://app.acme.com/callback"; const app = express(); app.use(session({ secret: process.env.SESSION_SECRET!, resave: false, saveUninitialized: false, cookie: { httpOnly: true, secure: true, sameSite: "lax" } })); app.get("/login", async (req, res) => { const verifier = client.randomPKCECodeVerifier(); const state = client.randomState(); req.session.login = { verifier, state, returnTo: req.query.returnTo }; res.redirect( client.buildAuthorizationUrl(config, { redirect_uri, scope: "openid profile email offline_access", code_challenge: await client.calculatePKCECodeChallenge(verifier), code_challenge_method: "S256", state, // prompt: "create" opens the sign-up form; ui_locales: "de" picks the language }).href, ); }); app.get("/callback", async (req, res) => { const { verifier, state, returnTo } = req.session.login ?? {}; const tokens = await client.authorizationCodeGrant(config, new URL(req.originalUrl, "https://app.acme.com"), { pkceCodeVerifier: verifier, expectedState: state }); req.session.login = undefined; req.session.user = tokens.claims(); // sub, email, name… req.session.tokens = { id_token: tokens.id_token, refresh_token: tokens.refresh_token, access_token: tokens.access_token, expires_at: Date.now() + (tokens.expires_in ?? 0) * 1000 }; res.redirect(typeof returnTo === "string" && returnTo.startsWith("/") ? returnTo : "/"); }); app.get("/logout", (req, res) => { const idToken = req.session.tokens?.id_token; req.session.destroy(() => { res.redirect(client.buildEndSessionUrl(config, { id_token_hint: idToken, post_logout_redirect_uri: "https://app.acme.com/" }).href); }); }); ``` Refresh an expired access token with `client.refreshTokenGrant(config, refreshToken)`; store the new refresh token it returns (they rotate). ## Next.js with Auth.js ```ts // auth.ts import NextAuth from "next-auth"; export const { handlers, auth, signIn, signOut } = NextAuth({ providers: [ { id: "9in", name: "9in", type: "oidc", issuer: process.env.NINE_ISSUER, // https://acme.9in.io clientId: process.env.NINE_CLIENT_ID, clientSecret: process.env.NINE_CLIENT_SECRET, authorization: { params: { scope: "openid profile email" } }, }, ], }); ``` Register `https://app.acme.com/api/auth/callback/9in` as the callback URL. Protect pages with `const session = await auth()` in server components, or `export { auth as middleware } from "./auth"`. ## The 9in Node SDK `@9in-io/node` wraps the same flow with 9in's defaults (PKCE, state, issuer and ID token checks, refresh with rotation): ```ts import { ServerAuth } from "@9in-io/node"; const auth = new ServerAuth({ issuer: process.env.NINE_ISSUER!, clientId: process.env.NINE_CLIENT_ID!, clientSecret: process.env.NINE_CLIENT_SECRET, redirectUri: "https://app.acme.com/callback", }); app.get("/login", async (req, res) => { const { url, pending } = await auth.createLogin({ returnTo: req.query.returnTo as string }); // screen: "signup" for the sign-up form req.session.nineLogin = pending; res.redirect(url); }); app.get("/callback", async (req, res) => { const tokens = await auth.handleCallback(req.originalUrl, req.session.nineLogin); req.session.nineLogin = undefined; req.session.tokens = tokens; req.session.user = auth.claims(tokens); res.redirect(tokens.returnTo ?? "/"); }); // later: req.session.tokens = await auth.ensureFresh(req.session.tokens) app.get("/logout", async (req, res) => { const tokens = req.session.tokens; if (tokens?.refreshToken) await auth.revoke(tokens.refreshToken); req.session.destroy(() => undefined); res.redirect(await auth.logoutUrl({ idToken: tokens?.idToken, returnTo: "https://app.acme.com/" })); }); ``` ## Other languages Any certified OpenID Connect relying-party library works with the issuer, client ID and secret: Spring Security (`spring.security.oauth2.client.provider.nine.issuer-uri`), ASP.NET Core (`AddOpenIdConnect` with `Authority`), Django (`mozilla-django-oidc`), Rails (`omniauth_openid_connect`), Go (`github.com/coreos/go-oidc`). Point them at the issuer; everything else comes from `/.well-known/openid-configuration`. ## Good to know - **Client authentication:** `client_secret_basic` (HTTP Basic) or `client_secret_post`. - **PKCE is recommended for confidential clients too** (OAuth 2.1); 9in accepts S256. - **Keep the ID token** to pass `id_token_hint` at sign-out (or pass `client_id`): 9in needs to know which application is signing out to accept its `post_logout_redirect_uri`. --- # PHP and Laravel > Sign users in from PHP and Laravel with the 9in packages or any OpenID Connect client. Source: https://9in.io/docs/php-laravel/ PHP apps sign users in as a **confidential client** (see [Server-rendered apps](https://9in.io/docs/server-apps/) for how that works). Use the 9in packages, or any OpenID Connect client such as `jumbojett/openid-connect-php`. Create the application first: **Applications → Create application → Regular Web App**, with your callback URL (for Laravel, `https://your-app.com/nine/callback`) and logout URL, and copy the client ID and secret. ## Laravel ```bash composer require 9in/laravel:^3.0 ``` ```dotenv NINE_ISSUER=https://acme.9in.io # or your custom login domain NINE_CLIENT_ID=app_… NINE_CLIENT_SECRET=… # NINE_AUDIENCE=https://api.acme.com # access tokens for your own API ``` Protect pages with the `nine:redirect` middleware: ```php Route::middleware(['web', 'nine:redirect'])->group(function () { Route::get('/dashboard', function () { $user = auth()->user(); // id (= sub), email, name, … from the ID token return view('dashboard', compact('user')); }); }); route('nine.login', ['return_to' => '/dashboard']); // add 'screen' => 'signup' for the sign-up form route('nine.logout'); ``` | Middleware | When signed out | |---|---| | `nine` | 401 JSON | | `nine:redirect` | Browsers go to the 9in sign-in page and come back; API requests get 401 | | `nine:passive` | Continue; `auth('nine')->user()` is null | | `nine.scope:orders:read,orders:write` | 403 unless the bearer token has every scope (use after `nine`) | Protect an API with the same guard. Tokens must be issued for `NINE_API_AUDIENCE` (defaults to `NINE_AUDIENCE`): ```php Route::middleware(['nine', 'nine.scope:orders:read'])->get('/api/orders', function () { $sub = auth()->id(); // 9in user id $claims = auth('nine')->claims(); // client_id, scope, aud, … }); ``` With the client ID and secret set, the guard verifies tokens in **hybrid** mode (local check, then one cached introspection), and the package's `/nine/webhook` route drops cached answers when it receives `token.revoked`, `session.revoked` or `user.*` events; set `NINE_WEBHOOK_SECRET` and subscribe that URL in the console. Publish the config with `php artisan vendor:publish --tag=nine:config` to change routes, scopes or caching. To keep your own `users` table, add a `nine_id` column and point the guard at your provider: ```php // config/auth.php 'guards' => [ 'nine' => ['driver' => 'nine', 'provider' => 'users', 'local_key' => 'nine_id'], ], ``` ## Plain PHP ```bash composer require 9in/php-sdk:^3.0 ``` ```php use Nine\OAuth2Client; use Nine\OAuth2Config; use Nine\Storage\FileStorage; $client = new OAuth2Client(OAuth2Config::fromEnv([ 'redirect_uri' => 'https://app.acme.com/callback', 'state_store' => new FileStorage('/var/lib/myapp/9in'), // pending logins (PKCE verifier, nonce) ])); // GET /login $login = $client->createAuthorizationUrl(['return_to' => '/dashboard']); // or ['screen' => 'signup'] header('Location: ' . $login['url']); // GET /callback $tokens = $client->handleCallback($_GET); // checks state + iss, exchanges the code, verifies the ID token $_SESSION['9in'] = $tokens->toArray(); header('Location: ' . ($tokens->returnTo ?? '/')); // later $tokens = $client->ensureFresh(\Nine\TokenSet::fromArray($_SESSION['9in'])); // refreshes (and rotates) // GET /logout $client->revoke($tokens->refreshToken); header('Location: ' . $client->logoutUrl($tokens->idToken, 'https://app.acme.com/')); ``` `OAuth2Config::fromEnv()` reads `NINE_ISSUER`, `NINE_CLIENT_ID`, `NINE_CLIENT_SECRET` and `NINE_REDIRECT_URI`. The same package has `TokenVerifier` and `HybridTokenVerifier` for APIs, `ManagementClient`, webhook verification and an adapter handler for [your own database](https://9in.io/docs/own-database/). ## Any OpenID Connect library ```php $oidc = new Jumbojett\OpenIDConnectClient('https://acme.9in.io', 'app_…', getenv('NINE_CLIENT_SECRET')); $oidc->setRedirectURL('https://app.acme.com/callback'); $oidc->addScope(['openid', 'profile', 'email']); $oidc->setCodeChallengeMethod('S256'); $oidc->authenticate(); // redirects, then handles the callback $user = $oidc->requestUserInfo(); // sub, email, name, … ``` ## Coming from SSOfy's packages The 9in packages replace `ssofy/*`. They still read the old `SSOFY_URL`, `SSOFY_CLIENT_ID` and `SSOFY_CLIENT_SECRET` when the `NINE_*` variables are missing. See [Coming from SSOfy](https://9in.io/docs/coming-from-ssofy/) for what else changed. --- # Mobile and desktop apps > Sign users in from iOS, Android, React Native, Flutter and desktop apps with the system browser and PKCE. Source: https://9in.io/docs/native-apps/ Mobile and desktop apps are **public clients**: they can't keep a secret, so they use the authorization code flow with **PKCE**, and they show the sign-in page in the **system browser** (ASWebAuthenticationSession on iOS, Custom Tabs on Android), never in an embedded web view. That keeps passwords out of your app, lets password managers fill them in, and gives single sign-on with the browser. ## Set up the application **Applications → Create application → Native / Mobile**, then add the redirect URI your app listens on to **Allowed callback URLs**: | Platform | Redirect URI | |---|---| | iOS / Android with a custom scheme | `com.acme.app:/callback` (reverse-domain scheme) | | Universal / App Links | `https://app.acme.com/callback` | | Desktop app with a loopback server | `http://127.0.0.1:8765/callback` (matched exactly, port included: listen on a fixed port) | ## iOS and Android: AppAuth [AppAuth](https://appauth.io) handles discovery, PKCE and the system browser. ```swift let issuer = URL(string: "https://acme.9in.io")! OIDAuthorizationService.discoverConfiguration(forIssuer: issuer) { config, _ in let request = OIDAuthorizationRequest( configuration: config!, clientId: "app_…", scopes: ["openid", "profile", "email", "offline_access"], redirectURL: URL(string: "com.acme.app:/callback")!, responseType: OIDResponseTypeCode, additionalParameters: nil) appDelegate.currentFlow = OIDAuthState.authState(byPresenting: request, presenting: self) { state, error in // state?.lastTokenResponse?.accessToken, state?.refreshToken } } ``` On Android use `net.openid:appauth` the same way: `AuthorizationServiceConfiguration.fetchFromIssuer`, then an `AuthorizationRequest` with `ResponseTypeValues.CODE`. ## React Native and Expo ```ts import * as AuthSession from "expo-auth-session"; const discovery = AuthSession.useAutoDiscovery("https://acme.9in.io"); const [request, response, promptAsync] = AuthSession.useAuthRequest( { clientId: "app_…", redirectUri: AuthSession.makeRedirectUri({ scheme: "com.acme.app", path: "callback" }), scopes: ["openid", "profile", "email", "offline_access"], usePKCE: true, }, discovery, ); // then AuthSession.exchangeCodeAsync({ code, clientId, redirectUri, extraParams: { code_verifier: request.codeVerifier } }, discovery) ``` `react-native-app-auth` works too, with `issuer`, `clientId`, `redirectUrl` and `scopes`. ## Flutter `flutter_appauth`: `authorizeAndExchangeCode(AuthorizationTokenRequest(clientId, redirectUrl, issuer: 'https://acme.9in.io', scopes: [...]))`. ## Keep tokens safe - Store refresh tokens in the Keychain (iOS) or EncryptedSharedPreferences / Keystore (Android). - Refresh tokens rotate: save the new one after every refresh. - To sign out, revoke the refresh token (`POST /oauth/revoke`) and open `/oauth/logout?id_token_hint=…&post_logout_redirect_uri=…` in the system browser to end the 9in session. Add that URI to **Allowed logout URLs**. --- # Machine to machine > Let services and scripts call your API, or the Management API, with the client credentials grant. Source: https://9in.io/docs/machine-to-machine/ Services, cron jobs and scripts get tokens for themselves with the **client credentials grant**: no user, just the application's own ID and secret. Use it to call your own APIs, or 9in's [Management API](https://9in.io/docs/reference/management-api/). ## Call your API 1. **APIs & Scopes → Create API**, e.g. `https://api.acme.com` with a scope `reports:read` (skip if it exists). 2. **Applications → Create application → Machine-to-Machine**. On its **API Access** tab, grant it the API and the scopes it may request. Copy the client ID and, from **Credentials**, the secret. 3. Ask for a token: ```bash curl -X POST https://acme.9in.io/oauth/token \ -u "app_svc123:$NINE_CLIENT_SECRET" \ -d grant_type=client_credentials \ -d audience=https://api.acme.com \ -d scope="reports:read" ``` ```json { "access_token": "eyJhbGciOi…", "token_type": "Bearer", "expires_in": 3600, "scope": "reports:read" } ``` Leave `scope` out to get every scope the application was granted for that API. Asking for a scope it wasn't granted fails with `invalid_scope`; an API it has no access to fails with `access_denied`; an unknown audience with `invalid_target`. The token's `sub` and `client_id` are the application's client ID, and there is no `sid`: that is how your API can tell a machine from a user. ## In code Cache the token until shortly before `expires_in` runs out; don't fetch one per request. ```ts let cached: { token: string; until: number } | null = null; export async function serviceToken(): Promise { if (cached && cached.until > Date.now() + 30_000) return cached.token; const res = await fetch("https://acme.9in.io/oauth/token", { method: "POST", headers: { Authorization: `Basic ${btoa(`${process.env.NINE_CLIENT_ID}:${process.env.NINE_CLIENT_SECRET}`)}`, "Content-Type": "application/x-www-form-urlencoded", }, body: new URLSearchParams({ grant_type: "client_credentials", audience: "https://api.acme.com" }), }); if (!res.ok) throw new Error(`token request failed: ${res.status} ${await res.text()}`); const { access_token, expires_in } = await res.json(); cached = { token: access_token, until: Date.now() + expires_in * 1000 }; return access_token; } ``` `openid-client` (`client.clientCredentialsGrant(config, { audience })`) and most OAuth libraries do the same. ## Call the Management API Give the application **management scopes** on its **API Access** tab (`management:users:read`, `management:users:write`, `management:sessions:write`), then either use the SDK, which gets and caches tokens itself: ```ts import { ManagementClient } from "@9in-io/sdk/server"; const nine = new ManagementClient({ issuer: "https://acme.9in.io", clientId: process.env.NINE_CLIENT_ID!, clientSecret: process.env.NINE_CLIENT_SECRET! }); const { user } = await nine.findUserByEmail("jane@acme.com"); await nine.updateUser(user.id, { metadata: { plan: "pro" } }); ``` or request a token without `audience`, with the management scopes you need: ```bash curl -X POST https://acme.9in.io/oauth/token -u "app_svc123:$NINE_CLIENT_SECRET" \ -d grant_type=client_credentials -d scope="management:users:read" ``` and call `https://acme.9in.io/api/v1/users?q=jane` with `Authorization: Bearer `. The sandbox has its own users: call `https://acme-sandbox.9in.io` for them. See the [Management API reference](https://9in.io/docs/reference/management-api/). ## Good to know - Only confidential applications can use this grant, and management scopes can only be given to them. - Removing a scope or deleting the application in the console takes effect at once for the Management API: every call re-checks the application. - The access token lifetime is the application's setting if it has one, else the API's, else the tenant default (1 hour). --- # TVs, CLIs and devices > Sign users in on devices without a keyboard or browser with the device authorization grant (RFC 8628). Source: https://9in.io/docs/devices/ Smart TVs, consoles, CLIs and other devices without a good keyboard or browser use the **device authorization grant** (RFC 8628): the device shows a short code, the user enters it on their phone or computer, and the device receives tokens once they approve. ## Set up the application **Applications → Create application → TV & Device**. It's a public client (no secret) with the device code and refresh token grants. ## 1. Ask for a code ```bash curl -X POST https://acme.9in.io/oauth/device/authorize \ -d client_id=app_tv123 \ -d scope="openid profile offline_access" # -d audience=https://api.acme.com tokens for your API ``` ```json { "device_code": "Ag5m…", "user_code": "BDWP-HQXZ", "verification_uri": "https://acme.9in.io/device", "verification_uri_complete": "https://acme.9in.io/device?user_code=BDWP-HQXZ", "expires_in": 600, "interval": 5 } ``` Show the user `verification_uri` and `user_code` (or a QR code of `verification_uri_complete`, which fills the code in). Codes are 8 letters without vowels, so they never spell words, and expire after 10 minutes. ## 2. Poll for tokens Every `interval` seconds: ```bash curl -X POST https://acme.9in.io/oauth/token \ -d grant_type=urn:ietf:params:oauth:grant-type:device_code \ -d device_code=Ag5m… \ -d client_id=app_tv123 ``` | Response | Meaning | What to do | |---|---|---| | `400 authorization_pending` | The user hasn't finished yet | Keep polling | | `400 slow_down` | You polled too fast | Add 5 seconds to the interval | | `400 access_denied` | The user declined | Stop; offer to start over | | `400 expired_token` | 10 minutes passed | Start over with a new code | | `200` with tokens | Approved | Store the tokens and stop polling | ## 3. Use the tokens The response is the same as any sign-in: an access token, an ID token with `openid`, and a refresh token with `offline_access`. Refresh with `grant_type=refresh_token` and your `client_id`; refresh tokens rotate, so store the new one each time. ## What the user sees At `verification_uri` the user signs in (if they aren't already), types the code, sees which app is asking and for what, and approves. The page is themed and translated like every other hosted page. --- # Protecting your API > Define an API, request tokens for it and verify them: locally, with live revocation (hybrid) or by introspection. Source: https://9in.io/docs/protecting-apis/ Your API receives access tokens in the `Authorization: Bearer …` header and decides whether to accept them. 9in gives you three ways to check them, trading speed for how quickly revocations take effect. ## Define the API **APIs & Scopes → Create API**: - **Identifier:** a URL-like name, usually the API's base URL (`https://api.acme.com`). Apps request tokens for it with `audience=https://api.acme.com`, and tokens carry it as `aud`. - **Scopes:** what the API lets callers do, such as `orders:read` and `orders:write`. Apps request them in `scope`; users approve them on the consent screen when the app isn't first-party. - Optional: **token format** (JWT or opaque), **signing key**, **access token lifetime**, and whether **refresh tokens** (`offline_access`) may be issued for it. ## Verify tokens ### Local (fastest) Check the signature against the tenant's public keys and the claims. No call to 9in per request; the keys are fetched once and cached. ```ts import { verifyAccessToken } from "@9in-io/sdk/server"; const claims = await verifyAccessToken(bearer, { issuer: "https://acme.9in.io", audience: "https://api.acme.com", scope: "orders:read", // every scope listed must be present }); // claims.sub — the user (or the client ID for machine tokens) // claims.scope, claims.client_id, claims.sid, claims.jti, claims.exp ``` A token that was revoked (sign-out, a blocked user, `/oauth/revoke`) still passes until it expires. Keep access tokens short-lived (the default is 1 hour) if you rely on this alone. ### Hybrid (recommended) A local check, then one introspection per token, cached. 9in's `token.revoked`, `session.revoked` and `user.*` [webhooks](https://9in.io/docs/webhooks/) drop the cached answers, so revocations apply within seconds while most requests stay local. ```ts import { createTokenVerifier, verifyWebhook } from "@9in-io/sdk/server"; const verifier = createTokenVerifier({ issuer: "https://acme.9in.io", audience: "https://api.acme.com", clientId: process.env.NINE_CLIENT_ID, // any confidential application of the tenant clientSecret: process.env.NINE_CLIENT_SECRET, // mode: "hybrid" (default) | "local" | "introspect" // cacheTtl: 300 seconds an answer is trusted without news from 9in }); const claims = await verifier.verify(bearer, { scope: "orders:read" }); // your webhook endpoint, subscribed to token.revoked, session.revoked, user.sessions_revoked, user.updated and user.deleted verifier.handleEvent(await verifyWebhook(process.env.NINE_WEBHOOK_SECRET!, request)); ``` The cache lives in memory, per process: with several processes, each needs the webhook events (or keep `cacheTtl` short). ### Introspection `mode: "introspect"` asks 9in about every token (`POST /oauth/introspect`, authenticated as a confidential application). Always current, one network call per request. Opaque tokens are always introspected, and cached in hybrid mode. ## Without the SDK Any JWT library works. Verify: 1. The header: `typ` is `at+jwt`, and `alg` is the algorithm of the key named by `kid` in `/.well-known/jwks.json`. Don't accept a fixed algorithm list that includes others, and never `none`. 2. The signature, with that key. 3. `iss` equals your issuer exactly; `aud` contains your API identifier; `exp` is in the future (allow a few seconds of clock skew). 4. `scope` (space-separated) contains what the route needs. ```python # Python with PyJWT import jwt jwks = jwt.PyJWKClient("https://acme.9in.io/.well-known/jwks.json") key = jwks.get_signing_key_from_jwt(token) claims = jwt.decode(token, key.key, algorithms=[key.algorithm_name], audience="https://api.acme.com", issuer="https://acme.9in.io", options={"require": ["exp", "iss", "aud"]}) assert jwt.get_unverified_header(token)["typ"] == "at+jwt" ``` Cache the JWKS, and fetch it again when a token names a `kid` you don't know (keys rotate). ## Shared-secret (HMAC) tokens An API can use an **HS256/384/512** key instead of a public key. Its secret is shown once in **Tokens & Keys**; the API verifies with it: ```ts const claims = await verifyAccessToken(bearer, { issuer, audience: "https://api.acme.com", secret: process.env.NINE_API_SECRET }); ``` HMAC keys never appear in the JWKS, and a token signed with one is only accepted for the API (or application) that uses that key, so a leaked secret can't mint tokens for other audiences. Prefer asymmetric keys unless your platform requires HMAC. ## Opaque tokens With **Opaque** as the API's token format, access tokens are random strings starting with `9in_at_`. They carry nothing, can't be verified locally, and are checked with introspection. Use them when tokens must not reveal anything to the client, or when every request should see revocations at once. ## Machines vs users A client-credentials token has `sub` equal to `client_id` and no `sid`. Check for that when a route is meant for users only. --- # Passwords, codes and two-factor > Password, passwordless one-time codes by email or SMS, authenticator-app two-factor and recovery codes. Source: https://9in.io/docs/sign-in-methods/ Everything here is set in the console under **Login & Sign-Up** and **Security**, and applies to every application of the tenant. Your apps don't change: they redirect to the hosted page, which shows what's turned on. ## Identifiers What people type to sign in: **email** (on by default), **username** and **phone number**. With several turned on, one field accepts any of them. For each identifier you choose whether it signs in with a **password**, a **one-time code**, or both. Phone numbers can be limited to some country codes. ## Passwords Passwords are hashed with argon2id. **Password rules** (length, required character kinds) apply when people choose a password. Imported hashes (bcrypt, PBKDF2, Firebase scrypt…) keep working and are upgraded to argon2id at the next sign-in. **Password recovery** sends a one-time code by email (or SMS for phone accounts) and lets the user pick a new password. You can send people to your own page instead (**Login & Sign-Up → Sign-In → Use your own recovery page instead**). ## One-time codes (passwordless) With one-time codes on, users get a 6-digit code (6 to 8, configurable) by email or text message instead of typing a password. Codes expire after 10 minutes (configurable, 1 to 60) and allow 5 attempts. If an account has both an email and a phone number, the user picks where to receive it. - **Email** codes are sent by 9in, with your logo and wording ([Emails and texts](https://9in.io/docs/emails-and-texts/)). - **SMS** codes go through 9in's Twilio account on paid plans, or through your own Twilio account on any plan (**Security → SMS delivery**), with a sender number or a Twilio Verify service ([Emails and texts](https://9in.io/docs/emails-and-texts/#text-messages)). Limit the countries texts may go to; it's the main defence against SMS pumping fraud. - With [your own user database](https://9in.io/docs/own-database/), 9in sends you an `otp.requested` event and you deliver the code yourself. In the sandbox, codes are always `000000` and nothing is sent. ## Sign-up **Login & Sign-Up → Sign-Up** turns self-registration on or off, chooses the fields the form asks for (name, username, phone, birthdate…, each optional or required, plus your own [custom fields](https://9in.io/docs/users/#custom-profile-fields)) and whether people are signed in straight after. You can also send people to your own sign-up page. Open the sign-up form directly with `prompt=create` on the authorization request (`auth.signup()` in the SDK). ## Two-factor authentication Two-factor uses an **authenticator app** (Google Authenticator, 1Password, Authy… any TOTP app, 6 digits every 30 seconds) after the first factor, with **10 single-use recovery codes** for when the phone is lost. **Security → Two-factor authentication**: | Policy | What happens | |---|---| | Off | Never asked | | Optional (default) | Users turn it on from their account page; once on, it's asked at every sign-in | | Required | Everyone sets it up at their next sign-in | It applies to every sign-in method, including social, enterprise and login links. The ID token's `amr` claim lists how the user signed in: `pwd` (password), `otp` (one-time code), `social`, `fed` (enterprise), `link` (login link), plus `mfa` after a second factor. Access tokens don't carry `amr`; your app can check the ID token before calling sensitive APIs, or the tenant can require two-factor for everyone. Users manage two-factor on their account page (`/account`). For someone who lost their phone and their recovery codes, an admin opens the user in the console and chooses **Reset 2FA**; the reset is recorded in the activity log, and if two-factor is required they set it up again at their next sign-in. ## Brute-force protection After 10 failed attempts (configurable) an account and the IP address are locked for 15 minutes (configurable). Bot protection with Cloudflare Turnstile can be added to sign-up, password recovery and sign-in (always, or only after failures). See [Sessions and security](https://9in.io/docs/security/). ## Legacy grants For applications that can't use redirects yet, the **password grant** (resource owner password credentials) can be turned on per first-party application, and the **implicit flow** per application. Both are off by default and not recommended: they bypass bot protection and single sign-on. The password grant also refuses users who have two-factor turned on (and everyone when the tenant requires it), with `invalid_grant`, because it can't ask for a second factor. Prefer the authorization code flow. --- # Social login > Google, GitHub, Microsoft, Apple and 10 more providers, with 9in's shared apps or your own. Source: https://9in.io/docs/social-login/ Social login adds "Continue with Google" (and others) to the hosted sign-in and sign-up pages. Turn providers on under **Connections → Social**; your apps don't change. ## Providers | Provider | ID | 9in's shared app | |---|---|---| | Google | `google` | Yes | | GitHub | `github` | Yes | | Microsoft (personal and work accounts) | `microsoft` | Yes | | Apple | `apple` | No | | Facebook | `facebook` | No | | LinkedIn | `linkedin` | No | | GitLab | `gitlab` | No | | Bitbucket | `bitbucket` | No | | Discord | `discord` | No | | Slack | `slack` | No | | Twitch | `twitch` | No | | X | `x` | No | | Amazon | `amazon` | No | | Yahoo | `yahoo` | No | **Ready to use:** Google, GitHub and Microsoft work as soon as you turn them on, through 9in's shared apps. The provider's consent screen then names 9in. Switch to your own app at any time to show your name instead, or to ask for extra scopes. **Bring your own app:** the others need an OAuth app you create in the provider's developer console. Each provider's settings in the console show the steps and the redirect URI to register: ```text https://acme.9in.io/oauth/social/callback ``` (With a [custom domain](https://9in.io/docs/custom-domains/), use `https://login.acme.com/oauth/social/callback`.) Paste the client ID and secret into the console; secrets are stored encrypted. ### Apple Sign in with Apple needs a **Services ID** (the client ID), your **Team ID**, and a **Sign in with Apple key** (Key ID and the `.p8` private key). Apple sends the user's name only on their first sign-in, and may hide their real email behind a relay address. ## Linking accounts When someone signs in with a provider for the first time, 9in either creates a new user or links the identity to an existing user with the same email. It links **only when the provider vouches for the address**: - Apple, LinkedIn, GitLab, Slack, Twitch, Yahoo and Discord: when the provider says the email is verified. - Google: for Gmail addresses and Google Workspace accounts (Google is authoritative for those). - GitHub and Bitbucket: the account's primary, verified email. - Microsoft, Facebook, X and Amazon: never. Their accounts can carry addresses the provider hasn't verified for this purpose, so a first sign-in with them always creates its own user. A user can have several identities. The Management API shows them under `identities` (`social:google` with Google's user id as `subject`, and so on). ## What you get Social users are normal users: the same `sub`, the same tokens, the same profile claims (name, picture, email when the provider shares it). Their identities appear in the console and in the Management API. Two-factor still applies if they have it on. ## Button order and look Buttons appear in the order of the providers above, styled by the [theme](https://9in.io/docs/themes/). A custom theme can show them differently, or not at all. --- # Enterprise SSO > Connect your customers' identity providers (Microsoft Entra ID, Okta, Google Workspace, any OIDC provider) with email-domain routing. Source: https://9in.io/docs/enterprise-sso/ Enterprise SSO lets the employees of your business customers sign in with their company account: Microsoft Entra ID, Okta, Google Workspace, ADFS 2016+, Keycloak, Ping, Auth0 or any OpenID Connect provider. Users are routed by their email domain, so `jane@bigcorp.com` goes straight to BigCorp's identity provider. 9in connects over **OpenID Connect**. SAML is not supported; every major enterprise IdP also offers OIDC. ## Add a connection Your customer creates an application ("app registration") in their identity provider, with this redirect URI: ```text https://acme.9in.io/oauth/enterprise/callback ``` and gives you its **issuer URL**, **client ID** and **client secret**. Then in the console, **Connections → Enterprise SSO → Add connection**: | Field | Example | Notes | |---|---|---| | Display name | BigCorp | Shown on the sign-in button | | Connection ID | `bigcorp` | Lowercase; appears in URLs and as `enterprise:bigcorp` in user identities | | Issuer URL | `https://login.microsoftonline.com//v2.0` | Must serve `/.well-known/openid-configuration` over https | | Client ID / secret | | The secret is stored encrypted | | Email domains | `bigcorp.com` | Users typing an address at these domains go straight to this provider (home realm discovery) | | Extra scopes | `groups` | Added to `openid email profile` | | Show a button | on | Otherwise reachable only by email domain | | Link existing accounts by email | on | Only for addresses at the connection's domains | ID tokens from the provider are fully verified against its keys (signature, issuer, audience, nonce). ## Email domains and trust The connection is authoritative for its **email domains**: an email at one of them is treated as verified, and (with linking on) links to an existing user with that email. Emails at other domains are kept as the provider sent them and never link. Only list domains the customer actually owns. ## Microsoft Entra ID - Use the directory (tenant) ID or one of its domains in the issuer, with `/v2.0` at the end: `https://login.microsoftonline.com//v2.0`. `common` and `organizations` don't work for a single customer: for any Microsoft account, turn on Microsoft under [social login](https://9in.io/docs/social-login/) instead. - **Entra External ID** (the successor to Azure AD B2C) works with its issuer, `https://.ciamlogin.com//v2.0`. - Work accounts carry no `email` claim unless the app registration adds it as an optional claim (**Token configuration → email**). Without it, 9in uses the user's sign-in name (UPN) when it's in one of the connection's domains, which Entra only allows for verified domains. - Client secrets expire after at most 24 months. Note the date and update the secret in the console before then. ## Okta, Google Workspace and others | Provider | Issuer | |---|---| | Okta | `https://.okta.com` (or `https://.okta.com/oauth2/default` for a custom authorization server) | | Google Workspace | `https://accounts.google.com` (with an *internal* OAuth client in the customer's Google Cloud project) | | Auth0 | `https://.auth0.com/` | | Keycloak | `https://sso.example.com/realms/` | | ADFS 2016+ | `https://adfs.example.com/adfs` | ## What users see On the sign-in page they type their work email; 9in recognises the domain and sends them to their company's sign-in, then back to your app. With **Show a button**, a "Continue with BigCorp" button is shown too. Enterprise users get `fed` in the ID token's `amr` claim and `enterprise:` among their identities. ## Troubleshooting When a connection fails, the user sees a "sign-in didn't complete" page and the reason is in the console's **Activity** log (`enterprise.error`): issuer mismatch, a wrong secret, a redirect URI the provider doesn't know, and so on. --- # Login links > Single-use sign-in URLs for magic links in your own emails, or to move a signed-in user from your app to 9in. Source: https://9in.io/docs/login-links/ A login link is a single-use URL that signs one user in. Use it for: - **Magic links in your own emails** ("Click to sign in"), sent by your mail system with your templates. - **Moving a signed-in user from your app to 9in** without asking for their password again, for example when you add 9in to an existing product and want users to land on the account page already signed in. Your back end creates links with the [Management API](https://9in.io/docs/reference/management-api/); browsers never can. ## Create a link The application needs the `management:users:write` scope (**Applications → your machine-to-machine app → API Access**). ```ts import { ManagementClient } from "@9in-io/sdk/server"; const nine = new ManagementClient({ issuer: "https://acme.9in.io", clientId, clientSecret }); const { url, expires_at } = await nine.createLoginLink(userId, { returnTo: "https://app.acme.com/callback", // where the user ends up ttlSeconds: 600, // 60 to 3600; default 600 }); // email `url`, or redirect the user to it ``` ```bash curl -X POST https://acme.9in.io/api/v1/users/usr_8k2…/login-link \ -H "Authorization: Bearer $MGMT_TOKEN" -H "Content-Type: application/json" \ -d '{ "return_to": "https://app.acme.com/callback", "ttl_seconds": 600 }' # → 201 { "url": "https://acme.9in.io/login/link?token=…", "expires_at": 1794200600 } ``` ## Where users land `return_to` must be one of: - a **registered callback URL** of one of your applications. The user is signed in to 9in and sent there; your app then starts a normal sign-in (`auth.login()`), which completes at once because the user has a session (single sign-on); - a **path on the login domain**, such as `/account`. Without `return_to`, the user lands on their account page. ## Rules - **Single use** and short-lived: a link works once, within its lifetime (1 minute to 1 hour, 10 minutes by default). - **Two-factor still applies:** a user with two-factor turned on is asked for their code after opening the link. - **Blocked users** can't get links (409). - Signing in with a link counts as a sign-in (`user.login` webhook) with `link` in the ID token's `amr`. - Every link is recorded: the console's activity log shows who created it (`user.login_link_created`). Admins can also create a 15-minute link for a user from the console (**Users → user → Sign-in link**) to help them sign in. --- # Sessions and security > Session lifetime, single sign-on, sign-out, brute-force lockouts, bot protection and SMS fraud limits. Source: https://9in.io/docs/security/ ## Sessions and single sign-on When a user signs in, 9in starts a session on the tenant's domain (an http-only cookie). Every application of the tenant shares it: a user signed in for one app isn't asked again by the next (single sign-on), unless that app sends `prompt=login` or `max_age`. - **Lifetime:** 30 days by default (**Security → Sessions**), extended as the user comes back (sliding expiry). - **Refresh tokens** belong to a session: they stop working when it ends. - **Users** see their sessions on the account page and can end any of them, or **Sign out everywhere**. - **Admins** can end sessions in the console (Users → user, or Sessions) or with the [Management API](https://9in.io/docs/reference/management-api/). ## Signing out | What | How | Effect | |---|---|---| | Your app only | Delete your app's tokens and cookie | The 9in session stays: the next sign-in is instant | | Your app and 9in | Redirect to `/oauth/logout?id_token_hint=…&post_logout_redirect_uri=…` (`auth.logout()`) | Ends the 9in session; other apps' refresh tokens from it stop working | | Everywhere | Account page → Sign out everywhere, or `DELETE /api/v1/users/{id}/sessions` | Ends every session of the user | `post_logout_redirect_uri` must be one of the application's **Allowed logout URLs**. Without one, the user sees a "You're signed out" page. Unknown or missing URLs never redirect elsewhere. ## Revocation Signing out, ending a session, blocking or deleting a user, refresh-token reuse and `POST /oauth/revoke` all take effect on 9in at once: refresh tokens stop working, introspection reports the tokens inactive, and `token.revoked` / `session.revoked` [webhooks](https://9in.io/docs/webhooks/) go out. JWT access tokens verified **locally** keep working until they expire; use hybrid verification to see revocations within seconds ([Protecting your API](https://9in.io/docs/protecting-apis/)). **Refresh-token rotation:** every refresh returns a new refresh token and invalidates the old one. If an old one is ever used again, which only happens when a token was stolen or copied, 9in revokes the whole family of tokens from that sign-in and records `token.reuse_detected` in the activity log. ## Brute-force protection - **Accounts:** after 10 failed sign-ins (3 to 100), the account is locked for 15 minutes (1 minute to 24 hours). The counter resets after a successful sign-in. - **IP addresses:** locked after ten times as many failures, across accounts. - **One-time codes:** 5 attempts per code (1 to 10), and limits on how many codes one address or IP can request. - Locked users see "Too many attempts. Please try again later." ## Bot protection **Security → Bot protection** adds Cloudflare Turnstile (invisible for most people, no puzzles) to: - sign-up and password recovery (on by default once enabled); - sign-in: never, only after failures (default), or always. 9in's own Turnstile widget covers the `*.9in.io` hostnames. [Custom domains](https://9in.io/docs/custom-domains/) need your own widget (site key and secret key), because Turnstile checks the hostname. ## SMS fraud Text messages cost money, which makes sign-up and code pages a target for "SMS pumping". 9in limits it: - **Allowed countries** (Security → SMS delivery): texts only go to numbers whose calling code you picked. Countries that share a code (+1 for the US, Canada and the Caribbean) are allowed together. - **Daily cap** on texts sent through 9in's account per tenant (per plan), and per-number and per-IP limits on code requests. - Refusals are logged (`sms.blocked`) and shown as console alerts. ## The hosted pages - Served only over HTTPS, with `X-Frame-Options: DENY` and `frame-ancestors 'none'` (no clickjacking), `nosniff`, `no-referrer`, `no-store` and a strict Content Security Policy: scripts need the page's nonce, and custom themes declare the outside origins they use. - Every form has CSRF protection; sign-in transactions expire. - Passwords are hashed with argon2id; secrets you store (client secrets, Twilio tokens, signing keys) are encrypted at rest. --- # Users and profiles > The user record, sign-up fields, custom profile fields and claims, name, password and username rules, verification and the account page. Source: https://9in.io/docs/users/ ## The user record | Field | Notes | |---|---| | `id` | `usr_…`, or the id kept from an [import](https://9in.io/docs/importing-users/). The `sub` claim. Never changes. | | `email`, `email_verified` | Lower-cased. Unique in the environment. | | `phone_number`, `phone_number_verified` | E.164 (`+447700900123`). Unique. | | `username` (also `preferred_username`) | Lower-cased. Unique. | | `name`, `given_name`, `middle_name`, `family_name`, `nickname` | | | `picture`, `website`, `gender`, `birthdate` (`YYYY-MM-DD`), `locale`, `zoneinfo` | Standard OpenID Connect profile claims | | `metadata` | Your own JSON data. Not shown to the user, not in tokens (except custom fields, below). | | `blocked` | A blocked user can't sign in; their sessions end and their tokens stop working. | | `createdAt`, `updatedAt`, `lastLoginAt`, `loginCount` | Times in milliseconds | A user needs at least one identifier: email, phone or username. Besides the record, a user has **identities** (password, `social:google`, `enterprise:bigcorp`…), **sessions**, and possibly **two-factor**. Read and change users in the console (**Users**) or with the [Management API](https://9in.io/docs/reference/management-api/). When you create or update a user through the API, three names differ from what you get back: send `emailVerified`, `phone` and `phoneVerified`. ## Sign-up fields **Login & Sign-Up → Sign-Up** chooses what the sign-up form asks for besides the identifier and password: name, given and family name, middle name, nickname, username, phone number, birthdate, gender, website, locale and time zone, each optional or required. ## Custom profile fields **Login & Sign-Up → Custom Fields** adds your own fields: company, role, a marketing opt-in, a plan… | Setting | Options | |---|---| | Key | `company` (lowercase letters, digits, `_`; standard claim names are reserved) | | Type | Text, Number, Checkbox, Date, Dropdown (with options) | | At sign-up | Hidden, Optional, Required | | On the account page | Hidden, Read-only, Editable | | Include as a claim | In ID tokens and `/oauth/userinfo`, with the `profile` scope | Values are stored in the user's metadata under `fields`, so the Management API reads and writes them there: ```json { "metadata": { "fields": { "company": "Acme", "newsletter": true } } } ``` `PATCH /api/v1/users/{id}` replaces `metadata` as a whole: read the user first and send the merged object. ## Name, password and username rules **Login & Sign-Up → Name, Password & Username Rules**: - **Names:** which languages names may be written in (for example English, Swedish and Persian allow Latin letters with accents and the Arabic script), minimum and maximum length, digits, symbols and emoji. Invisible and text-direction characters are never allowed, so names can't impersonate others. Rules apply when people type their name (sign-up and the account page); names from social and enterprise sign-in, imports and the API are kept as they are. - **Passwords:** minimum and maximum length and required character kinds. Checked when a password is chosen. - **Usernames:** length, whether dots, underscores and hyphens are allowed, and whether it must start with a letter. ## Verified emails and phone numbers An email or phone number becomes **verified** when the user proves they receive it: by signing in or resetting their password with a one-time code sent there, or by signing in with a provider that vouches for it ([social login](https://9in.io/docs/social-login/#linking-accounts), [enterprise SSO](https://9in.io/docs/enterprise-sso/)). Admins, imports and the API can set the flags directly. Verified emails are what accounts are linked by. ## The account page Every tenant has an account page at `/account`, themed like the sign-in pages, where signed-in users: - edit their profile (the fields you choose in **Login & Sign-Up → Account Page**) and custom fields; - change their password; - turn two-factor on or off and get new recovery codes; - see their sessions and end them, or sign out everywhere. Link to it from your app, or point the user menu to your own profile page instead (**Login & Sign-Up → Account Page → Send users to your own account page instead**). ## Blocking and deleting - **Block** (console, or `PATCH … { "blocked": true }`): the user can't sign in; their sessions end, refresh tokens stop working, and introspection reports their tokens inactive. Unblock to let them back in. - **Delete** (console, or `DELETE /api/v1/users/{id}`): removes the user, identities, sessions and tokens for good. A `user.deleted` webhook goes out. --- # Your own user database > Keep users in your database: 9in calls three signed endpoints to check passwords and look users up. Source: https://9in.io/docs/own-database/ You can keep users in your own database and still use 9in for sign-in, tokens, social login, two-factor and the hosted pages. 9in then asks your server, through three signed HTTP endpoints, to check passwords and look users up. This is the **adapter** (Connections → User database → Your database). ```text 9in ──POST /authenticate──▶ your server: is this the right password? 9in ──POST /user─────────▶ your server: who is this (by id or identifier)? 9in ──POST /event────────▶ your server: a user was created, changed, signed out; send this code ``` Users still get a 9in user (for sessions, tokens and identities), linked to your id. Passwords stay in your database; 9in never stores them. ## Set it up 1. Implement the endpoints (below) under a base URL, e.g. `https://api.acme.com/9in`. 2. In the console, **Connections → User Database → Your server (adapter)**: enter the base URL, save, and copy the **shared secret** it shows (once; you can generate a new one later). Then press **Test**. 3. Optional: **Import users on first login** copies each user into 9in hosted storage as they sign in, with their password once it has been checked. It also turns on sign-up and password changes, which then happen in 9in. When everyone has moved, switch the database to 9in hosted storage. ## With the SDK ```ts import { adapterHandler } from "@9in-io/sdk/server"; export default { fetch: adapterHandler({ secret: process.env.NINE_ADAPTER_SECRET!, // or [newSecret, oldSecret] while rotating async authenticate({ type, value }, password) { // type: "email" | "username" | "phone" const user = await db.findByLogin(type, value); if (!user) return "invalid"; if (user.disabled) return "blocked"; return (await verifyPassword(password, user.hash)) ? toAdapterUser(user) : "invalid"; }, async findUser(query) { // { id } or { identifier: { type, value } } const user = "id" in query ? await db.findById(query.id) : await db.findByLogin(query.identifier.type, query.identifier.value); return user ? toAdapterUser(user) : null; }, async onEvent(event) { if (event.type === "otp.requested") await sendCode(event.data.to, event.data.code); }, }), }; function toAdapterUser(u: DbUser) { return { id: String(u.id), email: u.email, email_verified: u.emailConfirmed, name: u.fullName, phone_number: u.phone }; } ``` `adapterHandler` verifies the request signature, routes the three paths, and signs the responses. It works in Node 18+, Workers, Deno and Bun; mount it on any framework that gives you a `Request`. `@9in-io/express-server` (Express) and the PHP and Laravel packages have equivalents. ## The endpoints All are `POST` with a JSON body, signed both ways (see [signatures](#signatures)). **`/authenticate`** checks a password: ```http POST /9in/authenticate { "identifier": { "type": "email", "value": "jane@acme.com" }, "password": "…" } 200 { "user": { "id": "42", "email": "jane@acme.com", "email_verified": true, "name": "Jane" } } 401 { "error": "invalid_credentials" } 403 { "error": "blocked" } ``` **`/user`** looks a user up, by your id or by an identifier: ```http POST /9in/user { "id": "42" } or { "identifier": { "type": "email", "value": "jane@acme.com" } } 200 { "user": { … } } 404 { "error": "not_found" } ``` **`/event`** receives notifications; answer `204` (any 2xx): ```http POST /9in/event { "id": "evt_…", "type": "otp.requested", "createdAt": "2026-11-09T08:00:00.000Z", "data": { "userId": "42", "channel": "email", "to": "jane@acme.com", "code": "482913", "purpose": "login", "expiresAt": "2026-11-09T08:10:00.000Z" } } ``` | Event | When | `data` | |---|---|---| | `otp.requested` | A one-time code must reach the user: you deliver it by email or SMS | `userId`, `channel` (`email`/`sms`), `to`, `code`, `purpose` (`login`/`password_reset`), `expiresAt` | | `user.updated` | The user changed their profile on the account page | `userId`, `fields` (the names that changed) | | `user.password_changed` | The user set a new password in 9in (with **Import users on first login**) | `userId`, `fields` | Passwords are never sent to you. For everything else (sign-ups, sign-ins, sessions), subscribe to [webhooks](https://9in.io/docs/webhooks/). The user object: `id` (required, your id), `email`, `email_verified`, `phone_number`, `phone_number_verified`, `username`, `name`, `given_name`, `family_name`, `picture`, `locale`, `zoneinfo`, `birthdate`, `blocked`, `metadata`. See the [adapter protocol reference](https://9in.io/docs/reference/adapter-protocol/) and [JSON Schemas](https://9in.io/schemas/adapter-user.json). ## Signatures Every request carries `9in-signature: t=,v1=`, where `v1` is HMAC-SHA256 with your signing secret over: ```text ... ``` Reject requests whose timestamp is more than 5 minutes off or whose signature doesn't match (compare in constant time). Sign your response the same way, over the same path plus `#` (the `9in-request-id` header) and your response body, so a response can't be replayed for another request. The SDK handlers do all of this. ## Good to know - 9in calls your server on each password sign-in and lookup, and waits up to 3 seconds (0.5 to 10, configurable). Keep the endpoints fast and available: if they fail or time out, users see "Sign-in is temporarily unavailable". - Social, enterprise and one-time-code sign-ins don't need `/authenticate`; they use `/user` to find the account. - The sandbox never calls your adapter: it creates test users on the fly. --- # Importing users > Move users from Auth0, Firebase, Cognito, Supabase, Clerk, Keycloak, Okta or your own database, with or without their passwords. Source: https://9in.io/docs/importing-users/ You can bring existing users to 9in without asking them to sign up again, and usually without asking for a new password. | Your users are in | Do this | |---|---| | Auth0, Firebase, Cognito, Supabase, Clerk, Keycloak or Okta | Export them, import the file in the console. Missing passwords come over with lazy migration. | | Your own database | Import a JSON or CSV file or use the Management API, or keep the database and connect it as an [adapter](https://9in.io/docs/own-database/) with *Import users on first login*. | | SSOfy | See [Coming from SSOfy](https://9in.io/docs/coming-from-ssofy/). | ## Import in the console **Import Users → Import a file**. Pick the source, drop the export, and 9in converts and checks it **in your browser**, showing what will be imported, what is skipped and why, before anything is uploaded. Then: - **Keep user ids:** the old ids become 9in user ids, so the `sub` your apps already store doesn't change (`auth0|60425dc4` stays `auth0|60425dc4`). - **Existing users:** skip them, or update them with the file's data. Social identities (Google, GitHub…) and original sign-up dates come along. ## Passwords | Format | Typical source | | |---|---|---| | bcrypt (`$2a$`, `$2b$`, `$2y$`) | Auth0 (hash export via support), Supabase, Clerk, most frameworks | Imported as is | | Argon2 (`argon2id`, `argon2i`, `argon2d`) | Keycloak, modern apps | Imported as is | | PBKDF2 (SHA-1, SHA-256, SHA-512) | Keycloak, Django | Imported as is | | Firebase scrypt | Firebase (with your project's hash parameters) | Imported as is | Imported hashes are checked as they are and upgraded to argon2id the next time each user signs in. ## Lazy migration Some providers don't export passwords (Cognito, Okta, Auth0 without the hash export). With **lazy migration** (**Import Users → Lazy migration**), when someone signs in and 9in has no password for them, 9in checks the password once with the old provider, then keeps it: | Source | Needs | |---|---| | Auth0 | Tenant domain, a client ID and secret with the password grant, the database connection name | | Amazon Cognito | Region and an app client with `ALLOW_USER_PASSWORD_AUTH` | | Firebase | The Web API key | | Supabase | The project URL and anon key | | Any OIDC provider with the password grant (Okta, Keycloak, Ping…) | Issuer, client ID and secret | Users who never come back can reset their password or sign in with a one-time code. Turn lazy migration off when you're done: no more calls to the old provider. ## Import with the API `POST /api/v1/users/import` takes up to 500 users per request (50 if they carry plain-text passwords), with the `management:users:write` scope: ```http POST https://acme.9in.io/api/v1/users/import Authorization: Bearer Content-Type: application/json { "on_conflict": "skip", "source": "legacy", "users": [ { "id": "42", "email": "jane@acme.com", "emailVerified": true, "name": "Jane Doe", "passwordHash": "$2b$10$…", "createdAt": 1680343200000, "metadata": { "plan": "pro", "fields": { "company": "Acme" } }, "identities": [{ "connection": "social:google", "subject": "1098…" }] } ] } ``` ```json { "results": [{ "index": 0, "ok": true, "status": "created", "id": "42" }] } ``` Each record succeeds or fails on its own; results come back in order with `created`, `updated`, `skipped` (with a `reason`) or `failed` (with an `error` and `details`). Records match existing users by id, email, phone, username or social identity. Every imported user produces a `user.imported` entry in the activity log. Identity connections are `social:`, `enterprise:` or `migration:`. Ids may contain letters, digits and `| _ . : @ + -` (up to 128 characters). ## Checklist 1. Import into the **sandbox** first (`https://acme-sandbox.9in.io`), try signing in as a few users, then import into production. 2. If ids change (you didn't keep them), map them in your app using the email or a value you put in `metadata`. 3. Turn on the [sign-in methods](https://9in.io/docs/sign-in-methods/) and [social providers](https://9in.io/docs/social-login/) your users had. --- # Webhooks > Get told when users sign up, change or sign in, and when sessions and tokens are revoked: signed, retried, deduplicated. Source: https://9in.io/docs/webhooks/ Webhooks tell your back end when something happens: a user signs up, changes, signs in or is deleted; a session or token is revoked. 9in POSTs a signed JSON event to your HTTPS endpoint and retries until you accept it. ## Add an endpoint **Advanced → Webhooks → Add endpoint**: your HTTPS URL and the events to send. The **signing secret** is shown once; you can generate a new one later. **Send test** delivers a `webhook.test` event so you can check your handler. Both production and sandbox events go to the same endpoints; check the event's `environment`. ## The request ```http POST /webhooks/9in HTTP/1.1 Host: api.acme.com Content-Type: application/json User-Agent: 9in-webhooks/1 9in-signature: t=1794200000,v1=5f2c8e… 9in-event-id: evt_k3m9q… 9in-event-type: user.created 9in-delivery-id: whd_… { "id": "evt_k3m9q…", "type": "user.created", "createdAt": "2026-11-09T08:00:00.000Z", "tenant": "tnt_…", "environment": "production", "data": { "userId": "usr_…", "user": { "id": "usr_…", "email": "jane@acme.com", … } } } ``` `data` carries the event's details plus, for user events, `userId` and a `user` snapshot (the [Management API user object](https://9in.io/docs/reference/management-api/), `null` once deleted). Every event type and its fields: [webhook events reference](https://9in.io/docs/reference/webhook-events/). JSON Schema: [/schemas/webhook-event.json](https://9in.io/schemas/webhook-event.json). ## Verify the signature `9in-signature` is `t=,v1=` over `t + ".POST." + path + "." + rawBody`, with your endpoint's secret. Check it against the **raw** body, before parsing. ```ts import { verifyWebhook } from "@9in-io/sdk/server"; export async function POST(request: Request) { const event = await verifyWebhook(process.env.NINE_WEBHOOK_SECRET!, request); // throws NineError("invalid_signature") switch (event.type) { case "user.created": await crm.addContact(event.data.user); break; case "user.deleted": await crm.removeContact(event.data.userId); break; } return new Response(null, { status: 204 }); } ``` `verifyWebhook` allows 5 minutes of clock skew, accepts several secrets while you rotate (`[newSecret, oldSecret]`), and reads the body, so pass `request.clone()` if you need it again. Behind a proxy that rewrites the path, pass the path 9in posted to: `verifyWebhook(secret, request, { path: "/webhooks/9in" })`. Without the SDK: ```python import hmac, hashlib, time def verify(secret: str, header: str, path: str, raw_body: bytes) -> bool: parts = dict(p.split("=", 1) for p in header.split(",")) if abs(time.time() - int(parts["t"])) > 300: return False signed = f'{parts["t"]}.POST.{path}.'.encode() + raw_body expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, parts["v1"]) ``` ## Delivery and retries - Answer with any **2xx within 10 seconds**. Do slow work in a queue. - Anything else (another status, a timeout, a network error, a redirect, which isn't followed) is retried after **1 minute, 6 minutes, 36 minutes, 3.6 hours and 21.6 hours**: six attempts over about a day. Then the delivery is marked failed and the console shows an alert. - The console lists every delivery with its status and response code, and can **redeliver** any event. - Events are delivered **at least once**, and not necessarily in order: use `createdAt`, and re-read the user when order matters. ## Process each event once Retries and redeliveries carry the same `id` (also in `9in-event-id`), and a retry can arrive while a slow first attempt is still running. `processEventOnce` runs your handler once per id: ```ts import { processEventOnce, verifyWebhook } from "@9in-io/sdk/server"; const event = await verifyWebhook(secret, request); const outcome = await processEventOnce(store, event.id, () => handle(event)); // processed or duplicate → 204; in_progress → 409, so 9in retries later return new Response(null, { status: outcome.status === "in_progress" ? 409 : 204 }); ``` `store` must add keys atomically: a Durable Object, SQL with a unique key, Redis `SET NX EX` (not Workers KV). `MemoryEventStore` covers a single process. ## Common uses - **Sync users** to your database or CRM: `user.created`, `user.updated`, `user.deleted`. - **Revoke cached tokens** in your API: `token.revoked`, `session.revoked`, `user.sessions_revoked` (see [hybrid verification](https://9in.io/docs/protecting-apis/#hybrid-recommended)). - **Security alerts:** `user.login_failed`, `user.password_changed`, `user.mfa_disabled`. --- # Coming from SSOfy > What changed from SSOfy and how to move an existing integration to 9in. Source: https://9in.io/docs/coming-from-ssofy/ 9in is SSOfy, rebuilt and relaunched on Cloudflare with standard OpenID Connect at its core. SSOfy's v1 endpoints and its signed API are not part of 9in, so an integration moves over rather than running unchanged. Most moves are small. ## What maps to what | SSOfy | 9in | |---|---| | `https://.ssofy.com` | `https://.9in.io` (or your custom domain) | | SSOfy SDKs (`ssofy/*`, `@ssofy/*`) | 9in SDKs: `@9in-io/sdk`, `@9in-io/node`, `@9in-io/express-server`, `9in/php-sdk`, `9in/laravel`. They read the old `SSOFY_*` environment variables when `NINE_*` ones are missing. Any OpenID Connect library works too. | | API key + request signatures | The [Management API](https://9in.io/docs/reference/management-api/) with a client-credentials token | | `auth/token` (signing a user in from your app) | [Login links](https://9in.io/docs/login-links/) | | reCAPTCHA / hCaptcha / image captcha | Cloudflare Turnstile ([bot protection](https://9in.io/docs/security/#bot-protection)) | | `custom_1` … `custom_9` | [Custom profile fields](https://9in.io/docs/users/#custom-profile-fields) with your own keys | | Hybrid token validation | `createTokenVerifier` in hybrid mode ([Protecting your API](https://9in.io/docs/protecting-apis/)) | ## Your user database Keep it. The adapter now has **three** endpoints instead of eight: `/authenticate`, `/user` and `/event` ([Your own user database](https://9in.io/docs/own-database/)). Applications, scopes, social login and one-time-code options are configured in 9in instead of being served from your server. Requests and responses are signed with `9in-signature` (HMAC-SHA256), and the SDK handlers do it for you. ## Themes SSOfy Handlebars themes import as they are: layout, page partials, `manifest.json` and translations (**Themes → Import theme**). 9in renders them with the same component and partial names; pages a theme doesn't include (such as the newer two-factor pages) come from the default theme. Inline scripts need `nonce="{{ csp_nonce }}"`, or `allowInlineScripts` in the manifest's `security` section for themes ported as they are. See the [theme reference](https://9in.io/docs/reference/themes/). ## Users Import them with their password hashes and their ids, so `sub` doesn't change ([Importing users](https://9in.io/docs/importing-users/)), or let them move over as they sign in, through the adapter with **Import users on first login**. ## Step by step 1. Create a tenant and recreate your applications (callback and logout URLs) and APIs. 2. Configure sign-in methods, social providers, branding and your theme. 3. Move users: import them, or connect your database. 4. Switch your apps to the 9in SDK for your stack (or an OIDC library) and the new issuer. 5. Replace SSOfy API calls with the Management API, and `auth/token` with login links. 6. Test everything in the sandbox, then go live. --- # Themes > Change how the hosted pages look: theme options, languages and texts in the console, or your own Handlebars templates. Source: https://9in.io/docs/themes/ The hosted pages (sign-in, sign-up, codes, two-factor, consent, account, device and errors) use a **theme**. The built-in **9in Default** theme is designed to be branded through options; for full control, write your own theme in Handlebars. ## Edit on the preview **Themes** in the console shows a live preview of every page, on desktop, tablet and phone, in each of your languages. With **Edit on page** on: - **Click anything** (the logo, a button, the card, the background, the side panel) to see its options in the inspector on the right: accent color, corners, logo images and alignment, card border, side panel and more. The path above them (Page › Layout › Card › Button) selects the elements around it. - **Click any text** to change it in place, in the language you're previewing. - **Dashed "+" chips** mark things that are switched off, such as "+ Show a side panel"; click one to turn it on. Things hidden at the current screen size are listed under **Hidden here**. - **Show an error** displays a typical error message for the page, so you can see and reword it. Changes are previewed instantly and go live when you save. The **Design** tab also lists every option, and **Themes** switches between the built-in theme and your own. ## Logos and images Upload images under **Branding**. The logo and icon there are the defaults everywhere (pages, emails, favicon); a theme can use other images from the same library for dark pages and the side panel. Uploaded files get private names made from their content, so their public URLs say nothing about the original file. ## Texts and languages Every text on the pages and in the emails exists in English, Arabic, Chinese, French, German, Hebrew, Japanese, Persian, Russian, Spanish and Turkish. Choose your languages under **Themes → Languages** (and the default, used when the browser asks for none of them). Pages follow the visitor's browser, or the `ui_locales` parameter of the authorization request, and right-to-left languages are laid out right to left. Change texts by clicking them in a preview, or in the full list under **Translations**, which also shows texts that aren't translated yet. Texts you leave alone keep getting 9in's updates. ## Per-application themes An application can use a different theme from the rest of the tenant (**Applications → app → Settings → Theme for this app**), for example a product with its own brand. ## Your own theme **Themes → Import theme**, or **Edit code** to start from the default theme in the browser editor with a live preview. A theme is a set of Handlebars templates: - `layout.hbs`: the page shell, with named blocks (`{{#component brand}}…{{/component}}`) placed with `{{#render brand}}{{/render}}`; - one partial per page: `login`, `registration`, `otp-options`, `otp-verification`, `password-recovery`, `password-reset`, `authorization`, `account`, `logout`, `error`, `lost`, `redirect`, `device`, `mfa`, `mfa-enroll`; - `manifest.json`: the options your theme offers in the console, and the outside origins it may load. Pages your theme doesn't include come from the default theme. Templates see translations as `{{ $.login.title }}`, your options as `{{ theme.button_color }}`, and the page's data (`application`, `user`, `errors`, `csrf`…). Scripts need `nonce="{{ csp_nonce }}"`. Uploaded CSS, scripts and fonts are referenced as `{{ assets_url }}`. Themes from SSOfy import as they are. Every variable, helper, page and manifest field is in the [theme reference](https://9in.io/docs/reference/themes/). ### Make your theme editable on the preview 9in works out what each option affects from your templates: anything you output from `{{ theme.x }}`, or show inside `{{#if theme.x}}`, becomes clickable, and when an option hides something, a "+" chip offers to turn it back on. Optional hints make it more precise: ```json { "version": 1, "options": { "Look": { "properties": { "button_color": { "title": "Accent color", "type": "color", "default": "#1a1a1a", "target": ".btn-primary", "css_var": "--primary", "preview": { "page": "login", "device": "desktop" } } } } } } ``` - `target`: a CSS selector for the elements an option styles, for options used only in CSS. - `css_var` (and `unit`, such as `px`): the custom property the option sets, so the preview updates instantly instead of re-rendering. The page also gets a `9in:options` event on `document`, so scripts that derive colors (readable text on the accent, for example) can recompute. - `preview`: the page and device where the option shows, so the console takes the preview there. In a template, `{{option-target "logo logo_position" name="Logo"}}` inside a tag marks an element by hand and names it in the console. On real pages it writes nothing. --- # Emails and texts > The emails and text messages 9in sends, how to change their wording per language, and the sender details. Source: https://9in.io/docs/emails-and-texts/ 9in sends two kinds of messages to users of tenants that store users in 9in: | Message | Sent when | By | |---|---|---| | Sign-in code | Someone signs in with a one-time code, or verifies an email address | Email, or SMS for phone numbers | | Password reset code | Someone asks to reset their password | Email, or SMS for phone numbers | With [your own user database](https://9in.io/docs/own-database/), 9in sends you an `otp.requested` event instead and you deliver the code. ## Preview and change them **Emails** in the console shows each message as users get it: the email with its inbox header, or the text message with how many SMS it is billed as. **Click the subject or the text in the preview to change it**, in each of your languages; it's saved as a [translation](https://9in.io/docs/themes/#texts-and-languages). The texts use placeholders that 9in fills in when sending: | Placeholder | Becomes | |---|---| | `{app}` | Your tenant's name | | `{code}` | The code (keep it in the text) | | `{minutes}` | How long the code is valid | ## What the email looks like - **Logo** from Branding. Most email clients don't show SVG images, so with an SVG logo emails use your icon (or your name) instead; upload a PNG version to show it. - **Code** in a large box, in your theme's accent color (when it's dark enough to read). - **Footer** with your name and the links from Branding (privacy policy, terms, support). Emails are sent as HTML with a plain-text part. ## Sender **Emails → Sender**: - **Sender name:** shown as the "From" name. Default: your tenant's name. - **Reply-to address:** where replies go instead of the no-reply sender. ## Text messages SMS go through 9in's Twilio account on paid plans, or your own Twilio account (**Security → SMS delivery**), only to the countries you allow. Keep them short: letters outside the basic Latin set (Arabic, Cyrillic, emoji…) make each SMS hold 70 characters instead of 160. With your own Twilio account you can give a **Twilio Verify** service (`VA…`) instead of, or as well as, a sender number. Twilio then sends the code in Verify's own wording, translated into the user's language and naming your app, from a sender registered for each country (no number to buy, no US 10DLC registration). If the Verify service has custom codes enabled (Twilio turns them on when asked), the code is 9in's own, with your code length, and 9in tells Verify when it was used. Otherwise Twilio generates the code, with the service's code length, and 9in checks the user's entry with Twilio. Either way your code lifetime and attempt limits apply. The text you write here is not used for those messages. The console shows when this applies, including when 9in's own account sends through Verify. ## Languages Each user gets messages in the language of the page they were on. The built-in texts exist in English, Arabic, Chinese, French, German, Hebrew, Japanese, Persian, Russian, Spanish and Turkish; you can change any of them, and texts you don't translate fall back to English. --- # Custom domains > Serve sign-in from your own domain, such as login.example.com, with automatic TLS. Source: https://9in.io/docs/custom-domains/ Serve sign-in from your own domain, such as `login.acme.com`. Users trust a sign-in page on your domain more, password managers match it to your product, and 9in's cookies are first-party for your site. ## Add a domain 1. **Custom Domains → Add domain**: enter the hostname, e.g. `login.acme.com`. 2. At your DNS provider, add the record the console shows: | Type | Name | Value | |---|---|---| | CNAME | `login.acme.com` | `cname.9in.io` | The console may also show an optional TXT record that proves ownership before you switch the CNAME, so there's no gap when you move a hostname that is already in use. 3. Wait for **Active**. 9in checks every 5 minutes; the certificate is issued and renewed automatically. The number of custom domains depends on your plan. ## What changes - The **issuer** for requests on that host is `https://login.acme.com`: tokens issued there have `iss: https://login.acme.com`, and discovery is at `https://login.acme.com/.well-known/openid-configuration`. Point your apps at it, and verify tokens with that issuer. - The `*.9in.io` hostnames keep working, with their own issuer. A token's `iss` is the host it was issued on, so don't mix them in one app. - Users, sessions and keys are the same on every hostname of the tenant. ## Update your configuration - **Social and enterprise providers using your own OAuth apps:** add `https://login.acme.com/oauth/social/callback` and `https://login.acme.com/oauth/enterprise/callback` to the redirect URIs there. - **Bot protection:** Turnstile checks hostnames, so custom domains need your own Turnstile widget (site key and secret key, **Security → Bot protection**). - **Your apps:** use the new issuer, and add the new logout and callback URLs if they changed. --- # OAuth and OIDC endpoints > Every endpoint of a tenant: discovery, authorize, token, userinfo, revoke, introspect, logout and device, with parameters and responses. Source: https://9in.io/docs/reference/endpoints/ Every endpoint is on the tenant's issuer: `https://.9in.io`, `https://-sandbox.9in.io` or your custom domain. `` below stands for it. | Method | Path | What | |---|---|---| | GET | `/.well-known/openid-configuration` | Discovery document | | GET | `/.well-known/jwks.json` | Public signing keys | | GET, POST | `/oauth/authorize` | Start a sign-in | | POST | `/oauth/token` | Exchange a code, refresh, client credentials, device code, password | | POST | `/oauth/device/authorize` | Start a device sign-in (RFC 8628) | | GET, POST | `/oauth/userinfo` | The user's claims, for an access token | | POST | `/oauth/revoke` | Revoke a refresh or access token (RFC 7009) | | POST | `/oauth/introspect` | Is a token live? (RFC 7662) | | GET, POST | `/oauth/logout` | Sign out (RP-initiated logout) | | | `/api/v1/…` | The [Management API](https://9in.io/docs/reference/management-api/) | The hosted pages (`/login`, `/register`, `/password/recovery`, `/account`, `/device`, …) are for people, not for API calls; link to `/account` from your app if you like. ## Discovery `GET /.well-known/openid-configuration` returns the endpoints above and what's supported: | Field | Value | |---|---| | `response_types_supported` | `code`, `id_token`, `id_token token` | | `response_modes_supported` | `query`, `fragment`, `form_post` | | `grant_types_supported` | `authorization_code`, `refresh_token`, `client_credentials`, `urn:ietf:params:oauth:grant-type:device_code`, `password`, `implicit` (the last two only for applications that enable them) | | `code_challenge_methods_supported` | `S256` | | `token_endpoint_auth_methods_supported` | `client_secret_basic`, `client_secret_post`, `none` (public clients) | | `id_token_signing_alg_values_supported` | The algorithms of the tenant's keys | | `prompt_values_supported` | `none`, `login`, `consent`, `select_account`, `create` | | `ui_locales_supported` | The tenant's languages | | `authorization_response_iss_parameter_supported` | `true` | | `scopes_supported` | The standard scopes, plus every API's scopes | ## GET /oauth/authorize Redirect the browser here to sign a user in. `POST` with the same parameters as a form also works. | Parameter | Required | | |---|---|---| | `response_type` | Yes | `code`. (`id_token` and `id_token token` only for applications with the implicit flow turned on.) | | `client_id` | Yes | | | `redirect_uri` | Yes | Must exactly match one of the application's callback URLs | | `scope` | | Space-separated. Include `openid` for an ID token, `offline_access` for a refresh token. Default `openid`. | | `state` | Recommended | Returned unchanged | | `code_challenge`, `code_challenge_method` | Public clients | PKCE: `S256` only | | `nonce` | | Copied into the ID token | | `audience` (or `resource`) | | An API identifier: the access token is issued for that API | | `prompt` | | `login` (sign in again), `none` (no UI: fail with `login_required` or `consent_required`), `consent`, `select_account`, `create` (open the sign-up form) | | `max_age` | | Seconds: ask the user to sign in again if their last sign-in is older | | `login_hint` | | Pre-fills the identifier | | `ui_locales` | | Preferred languages, e.g. `de en` | | `response_mode` | | `query` (default for `code`), `fragment`, `form_post` | `request` and `request_uri` (signed request objects) are not supported. **Success:** a redirect to `redirect_uri?code=…&state=…&iss=`. Check `state`, and `iss` if your library supports it. **Errors:** with a valid client and redirect URI, a redirect to `redirect_uri?error=…&error_description=…&state=…`: `invalid_request`, `unauthorized_client`, `unsupported_response_type`, `invalid_scope`, `invalid_target` (unknown `audience`), `access_denied` (the user declined), `login_required`, `consent_required`, `request_not_supported`. An unknown client or redirect URI shows an error page instead of redirecting. ## POST /oauth/token Form-encoded (`application/x-www-form-urlencoded`). Confidential applications authenticate with HTTP Basic (`client_id:client_secret`, URL-encoded) or `client_id` and `client_secret` in the form; public applications send `client_id`. Browsers may call it from an **allowed web origin** (CORS). | `grant_type` | Parameters | |---|---| | `authorization_code` | `code`, `redirect_uri`, `code_verifier` (PKCE) | | `refresh_token` | `refresh_token`, optional narrower `scope` | | `client_credentials` | Optional `audience` and `scope` ([machine to machine](https://9in.io/docs/machine-to-machine/)) | | `urn:ietf:params:oauth:grant-type:device_code` | `device_code` ([devices](https://9in.io/docs/devices/)) | | `password` | `username`, `password`, `scope`, optional `audience` (first-party applications that enable it; refused for users with two-factor) | ```json { "access_token": "eyJ…", "token_type": "Bearer", "expires_in": 3600, "scope": "openid profile email offline_access", "id_token": "eyJ…", "refresh_token": "…" } ``` `id_token` comes with the `openid` scope; `refresh_token` with `offline_access` (when the application and the API allow refresh tokens). Refresh tokens **rotate**: each refresh returns a new one, and using an old one again revokes the whole family. **Errors** (JSON `{ "error", "error_description" }`): `invalid_request`, `invalid_client` (401), `invalid_grant` (bad or used code, wrong verifier, expired or revoked refresh token, wrong password), `unauthorized_client` (grant not allowed for this application), `unsupported_grant_type`, `invalid_scope`, `invalid_target`, `access_denied`, and for device codes `authorization_pending`, `slow_down`, `expired_token`. ## POST /oauth/device/authorize `client_id`, `scope`, optional `audience`. Returns `device_code`, `user_code` (`XXXX-XXXX`), `verification_uri` (`/device`), `verification_uri_complete`, `expires_in` (600) and `interval` (5). See [TVs, CLIs and devices](https://9in.io/docs/devices/). ## GET /oauth/userinfo `Authorization: Bearer ` (a token with the `openid` scope). Returns `sub` plus the claims the token's scopes allow ([scopes and claims](https://9in.io/docs/reference/tokens/#scopes-and-claims)) and custom fields marked as claims. Revoked tokens get `401`. ## POST /oauth/revoke `token`, optional `token_type_hint` (`refresh_token` or `access_token`), with client authentication (or `client_id` for public clients). Revoking a refresh token revokes its family; revoking a JWT access token puts its `jti` on a deny-list checked by introspection and hybrid verification. Always answers `200` for an unknown token, as RFC 7009 requires. ## POST /oauth/introspect `token`, authenticated as a **confidential** application of the tenant. Live state: revoked tokens, ended sessions and blocked users are `{ "active": false }`. ```json { "active": true, "token_type": "access_token", "scope": "orders:read", "client_id": "app_7f3k2m9q", "sub": "usr_8k2…", "aud": "https://api.acme.com", "iss": "https://acme.9in.io", "exp": 1794203600, "iat": 1794200000, "jti": "…", "sid": "…", "auth_time": 1794199000 } ``` ## GET /oauth/logout Ends the 9in session, then redirects to `post_logout_redirect_uri` (with `state`) or shows a "You're signed out" page. | Parameter | | |---|---| | `id_token_hint` | An ID token the tenant issued (expired is fine): identifies the application | | `client_id` | Identifies the application when there's no `id_token_hint` | | `post_logout_redirect_uri` | One of the application's allowed logout URLs | | `state` | Added to the redirect | ## GET /.well-known/jwks.json The public keys that verify tokens: every active and retired asymmetric key (RS, PS, ES, EdDSA). HMAC keys are never published. Cache it; fetch it again when a token names a `kid` you don't know. --- # Tokens and claims > Access, ID and refresh tokens, their claims and lifetimes, scopes and the claims they add, signing keys and algorithms. Source: https://9in.io/docs/reference/tokens/ ## Access tokens For your API. By default a JWT ([RFC 9068](https://www.rfc-editor.org/rfc/rfc9068)): ```json // header { "alg": "RS256", "kid": "k_8f2a…", "typ": "at+jwt" } // payload { "iss": "https://acme.9in.io", "sub": "usr_8k2…", // the user, or the client ID for client-credentials tokens "aud": "https://api.acme.com", // the API (the client ID when no audience was requested) "client_id": "app_7f3k2m9q", "scope": "openid profile orders:read", "iat": 1794200000, "exp": 1794203600, "jti": "…", "sid": "…", // the session (absent for client credentials) "auth_time": 1794199000 } ``` **Opaque access tokens** (`9in_at_…` followed by random characters) carry nothing: check them with [introspection](https://9in.io/docs/reference/endpoints/#post-oauth-introspect). Choose the format per tenant (**Tokens & Keys**), per API, or per application for tokens without an API. Access tokens don't include profile claims or `amr`; get those from the ID token or `/oauth/userinfo`. ## ID tokens For your app: who signed in and how. A JWT with `typ: JWT`, always signed with the tenant's **default key** (asymmetric). | Claim | | |---|---| | `iss`, `sub`, `aud` (your client ID), `azp`, `iat`, `exp` | | | `auth_time` | When the user signed in (unix seconds) | | `nonce` | From the authorization request | | `sid` | The session id | | `amr` | How the user signed in: `pwd`, `otp`, `social`, `fed` (enterprise), `link` (login link), and `mfa` after a second factor | | `at_hash` | Hash of the access token issued with it (OIDC) | | Profile claims | Depending on the scopes (below) | | Custom fields | Fields marked "include as a claim", with the `profile` scope | ## Refresh tokens Opaque, issued with `offline_access` when the application has the refresh token grant (and the API allows refresh tokens). They **rotate**: every use returns a new refresh token. Using one that was already used revokes the whole family (`token.reuse_detected` in the activity log). They stop working when their session ends, the user is blocked or deleted, or they're revoked. ## Lifetimes | | Default | Range | Set per | |---|---|---|---| | Access token | 1 hour | 1 minute – 24 hours | Tenant, API, application | | ID token | 1 hour | 1 minute – 24 hours | Tenant, application | | Refresh token | 30 days | 5 minutes – 1 year | Tenant, application | | Authorization code | 60 seconds | 10 seconds – 10 minutes | Tenant | | Session | 30 days (sliding) | 5 minutes – 1 year | Tenant | For access tokens, an application's lifetime wins over its API's, which wins over the tenant's. ## Scopes and claims | Scope | Gives | |---|---| | `openid` | An ID token; `sub` | | `profile` | `name`, `given_name`, `middle_name`, `family_name`, `nickname`, `preferred_username`, `picture`, `website`, `gender`, `locale`, `zoneinfo`, `birthdate`, and custom-field claims | | `email` | `email`, `email_verified` | | `phone` | `phone_number`, `phone_number_verified` | | `offline_access` | A refresh token | | Your API's scopes | Listed in the access token's `scope` | Claims with no value are left out. ## Signing keys Each tenant has a key library (**Tokens & Keys**): - **Algorithms:** RS256, RS384, RS512, PS256, PS384, PS512 (RSA 2048 or 4096 bits), ES256, ES384, EdDSA (Ed25519), and HS256, HS384, HS512 (shared secrets). - **Generate** keys, or **import** your own: a PKCS#8 PEM, a private JWK, or an HMAC secret. - The **default key** (asymmetric) signs ID tokens and every access token whose API or application doesn't choose another key. New tenants start with an RS256 key. - **Per API or application:** any key from the library, so an API can use ES256 or a shared HMAC secret. - **Rotation:** add a key, make it the default, then **retire** the old one. Retired keys stop signing but stay in the JWKS until you delete them, so tokens signed earlier keep verifying. - **HMAC keys** are never published; the secret is shown once when it's created. A token signed with one is only accepted for the API or application that uses that key. Verifiers must use the algorithm of the key named by `kid`, not a fixed list: an API's key can be ES256 or EdDSA. --- # Management API > Create, find, update, import, block and delete users, create login links and end sessions over HTTPS. Source: https://9in.io/docs/reference/management-api/ Manage the users and sessions of one tenant environment over HTTPS. The machine-readable version is [/openapi.json](https://9in.io/openapi.json) (OpenAPI 3.1). ## Base URL and authentication ```text https://.9in.io/api/v1 production https://-sandbox.9in.io/api/v1 sandbox (separate users) https://login.acme.com/api/v1 a custom domain ``` Every request needs `Authorization: Bearer `, a client-credentials token of a **confidential** application that was given management scopes (**Applications → app → API Access → Management API**): | Scope | Allows | |---|---| | `management:users:read` | List, search and read users and their sessions | | `management:users:write` | Create, update, import, block and delete users; create login links | | `management:sessions:write` | End sessions | ```bash TOKEN=$(curl -s -X POST https://acme.9in.io/oauth/token -u "$CLIENT_ID:$CLIENT_SECRET" \ -d grant_type=client_credentials -d scope="management:users:read management:users:write" | jq -r .access_token) ``` The application is re-checked on every call: removing a scope or deleting the application takes effect at once. With `@9in-io/sdk/server`, `ManagementClient` gets and caches tokens itself, asking each call for only the scope it needs. ## Endpoints | Method | Path | Scope | | |---|---|---|---| | GET | `/users?q=&limit=&cursor=` | users:read | List or search users | | GET | `/users/lookup?email=` (or `phone`, `username`) | users:read | Find one user | | GET | `/users/{id}` | users:read | A user with identities, sessions and two-factor state | | POST | `/users` | users:write | Create a user | | PATCH | `/users/{id}` | users:write | Update a user | | DELETE | `/users/{id}` | users:write | Delete a user | | POST | `/users/import` | users:write | Import up to 500 users | | POST | `/users/{id}/login-link` | users:write | Create a single-use sign-in link | | GET | `/users/{id}/sessions` | users:read | Active sessions | | DELETE | `/users/{id}/sessions` | sessions:write | End all of a user's sessions | | DELETE | `/sessions/{sid}` | sessions:write | End one session | ## The user object What the API returns (OpenID Connect claim names): ```json { "id": "usr_8k2m4q", "email": "jane@acme.com", "email_verified": true, "phone_number": null, "phone_number_verified": false, "username": null, "preferred_username": null, "name": "Jane Doe", "given_name": "Jane", "middle_name": null, "family_name": "Doe", "nickname": null, "gender": null, "website": null, "picture": null, "locale": "en", "zoneinfo": null, "birthdate": null, "blocked": false, "metadata": { "plan": "pro", "fields": { "company": "Acme" } }, "createdAt": 1794200000000, "updatedAt": 1794200000000, "lastLoginAt": 1794203000000, "loginCount": 12 } ``` Times are milliseconds since the epoch. Custom profile fields live in `metadata.fields`. ## Creating and updating `POST /users` and `PATCH /users/{id}` take these fields. **Three names differ from the output:** `emailVerified`, `phone` and `phoneVerified`. | Field | Type | | |---|---|---| | `email` | string or null | At least one of email, phone or username when creating | | `emailVerified` | boolean | | | `phone` | string or null | E.164, e.g. `+447700900123` | | `phoneVerified` | boolean | | | `username` | string or null | | | `name`, `given_name`, `family_name` | string or null | | | `picture` | https URL or null | | | `locale`, `zoneinfo` | string or null | | | `birthdate` | `YYYY-MM-DD` or null | | | `metadata` | object | **Replaces** the stored metadata: read, merge, then send | | `blocked` | boolean | `true` ends sessions and stops tokens | | `password` | string | Plain text; hashed with argon2id | | `passwordHash` | string | An existing bcrypt, argon2, PBKDF2 or Firebase scrypt hash | ```bash curl -X POST https://acme.9in.io/api/v1/users -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{ "email": "jane@acme.com", "emailVerified": true, "name": "Jane Doe", "password": "…" }' # → 201 { "user": { … } } ``` Errors: `400 invalid_user` (with `details: [{ path, message }]`), `invalid_email`, `invalid_phone`, `identifier_required`, `unsupported_password_hash`; `409 identifier_taken`; `404 not_found`. ## Listing and searching `GET /users` returns newest first: `{ "users": [...], "nextCursor": "…" | null, "total": 1234 }`. Pass `nextCursor` back as `cursor` for the next page. `q` matches email, username, phone and name (substring) or an exact id; `limit` is 1 to 200 (default 50). ## Getting one user `GET /users/{id}`: ```json { "user": { … }, "identities": [{ "connection": "social:google", "subject": "1098…", "userId": "usr_…", "profile": {}, "createdAt": 1794200000000 }], "sessions": [{ "sid": "…", "userId": "usr_…", "authTime": 1794199000, "amr": ["pwd"], "ip": "81.2.69.160", "userAgent": "…", "createdAt": …, "lastSeenAt": …, "expiresAt": …, "revokedAt": null }], "hasPassword": true, "mfa": { "enabled": true, "since": 1794100000000, "recoveryCodesLeft": 9 } } ``` ## Importing `POST /users/import` with `{ "users": [...], "on_conflict": "skip" | "update", "source": "legacy" }`. Up to 500 users (50 with plain-text passwords). Records may also carry `id` (kept as the user id), `identities` (`[{ connection, subject }]`), `createdAt` and `lastLoginAt`. Returns `{ "results": [{ index, ok, status: "created" | "updated" | "skipped" | "failed", id?, reason?, error?, details? }] }`, one per record, in order. See [Importing users](https://9in.io/docs/importing-users/). ## Login links `POST /users/{id}/login-link` with optional `{ "return_to": "https://app.acme.com/callback", "ttl_seconds": 600 }` returns `201 { "url": "…", "expires_at": 1794200600 }`. See [Login links](https://9in.io/docs/login-links/). ## Errors ```json { "error": "insufficient_scope", "error_description": "requires management:users:write" } ``` | Status | `error` | |---|---| | 400 | `invalid_request`, `invalid_user`, `invalid_email`, `invalid_phone`, `identifier_required`, `unsupported_password_hash` | | 401 | `invalid_token` (missing, expired, revoked, not a client-credentials token) | | 403 | `insufficient_scope` | | 404 | `not_found`, `user_not_found` | | 409 | `identifier_taken`, or the user can't sign in (login links) | --- # JavaScript SDK > Every class, function and option of @9in-io/sdk and @9in-io/sdk/server. Source: https://9in.io/docs/reference/sdk/ `@9in-io/sdk` has no dependencies and runs in browsers, Node 18+, Cloudflare Workers, Deno and Bun. It ships TypeScript types. ```bash npm install @9in-io/sdk ``` | Import | For | |---|---| | `@9in-io/sdk` | Signing users in from browser apps: `NineAuth` | | `@9in-io/sdk/server` | Back ends: token verification, the Management API, webhooks, the adapter | Every failure is a `NineError` with a `code` (and `status` for HTTP errors). Issuers must use `https://`; plain http is accepted for `localhost`, `*.localhost` and loopback addresses, or anywhere with `allowInsecureIssuer: true` (development only). ## NineAuth (`@9in-io/sdk`) ```ts const auth = new NineAuth(options); ``` | Option | Type | | |---|---|---| | `issuer` | string | Your tenant's issuer, e.g. `https://acme.9in.io` | | `clientId` | string | | | `redirectUri` | string | A registered callback URL | | `scope` | string | Default `"openid profile email offline_access"` | | `audience` | string | API identifier for access tokens | | `storage` | `StorageLike` | Where tokens are kept (default `localStorage`, else memory) | | `transientStorage` | `StorageLike` | Where the PKCE verifier and state wait during the redirect (default `sessionStorage`) | | `locale` | string | Language of the hosted pages (`ui_locales`) | | `navigate` | `(url) => void` | Default `window.location.assign` | | `fetch` | `typeof fetch` | Custom fetch (tests, React Native) | | `allowInsecureIssuer` | boolean | | `StorageLike` is `{ getItem(key), setItem(key, value), removeItem(key) }`. | Method | Returns | | |---|---|---| | `login(options?)` | `Promise` | Redirects to the hosted sign-in (authorization code + PKCE) | | `signup(options?)` | `Promise` | Opens the sign-up form (`prompt=create`) | | `buildLoginUrl(options?)` | `Promise` | The URL `login()` would navigate to | | `handleRedirect(url?)` | `Promise<{ user: User; returnTo?: string }>` | On the callback page: checks `state` and `iss`, exchanges the code, stores tokens, cleans the address bar | | `isAuthenticated()` | boolean | A usable access token or a refresh token is stored | | `getUser()` | `Promise` | Claims from the ID token | | `fetchUserInfo()` | `Promise` | Fresh claims from `/oauth/userinfo` | | `getAccessToken()` | `Promise` | Refreshed (with rotation) when it expires within a minute; concurrent callers share one refresh. Throws `login_required` when signed out | | `getTokens()` | `TokenSet \| null` | `{ accessToken, idToken, refreshToken, expiresAt, scope }` | | `fetch(input, init?)` | `Promise` | `fetch` with `Authorization: Bearer` | | `logout(options?)` | `Promise` | Revokes the refresh token, clears tokens and ends the 9in session. `{ returnTo }` (an allowed logout URL); `{ local: true }` only clears local tokens | `LoginOptions`: `prompt` (`"login" \| "consent" \| "none" \| "select_account"`), `loginHint`, `screen` (`"login" \| "signup"`), `returnTo` (handed back by `handleRedirect`), `params` (extra authorization parameters). ## verifyAccessToken (`@9in-io/sdk/server`) ```ts verifyAccessToken(token: string | null | undefined, options: VerifyOptions): Promise ``` Verifies a JWT access token locally: `typ: at+jwt`, the signature with the key named by `kid` (JWKS cached, refetched once for an unknown `kid`) using that key's own algorithm, `iss`, `aud`, `exp`, and scopes. | Option | | |---|---| | `issuer` | Exactly the token's `iss` | | `audience` | string or string[]: the API identifier(s) accepted | | `scope` | string (space-separated) or string[]: all required | | `secret` | The API's HMAC secret, for HS256/384/512 tokens | | `clockTolerance` | Seconds, default 30 | | `fetch`, `allowInsecureIssuer` | | Throws `NineError` with `invalid_token` or `insufficient_scope`. `AccessTokenClaims`: `iss`, `sub`, `aud`, `exp`, `iat`, `client_id`, `scope`, `jti?`, `sid?`, `auth_time?`. ## createTokenVerifier ```ts const verifier = createTokenVerifier(options: TokenVerifierOptions): TokenVerifier ``` `VerifyOptions` (without `scope`) plus: | Option | | |---|---| | `mode` | `"hybrid"` (default), `"local"`, `"introspect"`. Hybrid and introspect need `clientId` and `clientSecret` (`invalid_options` otherwise) | | `clientId`, `clientSecret` | A confidential application, for introspection | | `cacheTtl` | Seconds a hybrid answer is trusted without news (default 300) | | `cacheSize` | Most tokens cached (default 10 000) | | Method | | |---|---| | `verify(token, { scope? })` | Claims, or throws `NineError`. Opaque tokens are always introspected | | `handleEvent(event)` | Drops cached answers a webhook event makes stale; returns how many | | `clear()` | Forgets every cached answer | ## ManagementClient ```ts const nine = new ManagementClient({ issuer, clientId, clientSecret, fetch?, allowInsecureIssuer? }); ``` Gets client-credentials tokens itself, one per management scope, and caches them. | Method | API call | |---|---| | `listUsers({ query?, cursor?, limit? })` | `GET /users` → `{ users, nextCursor }` | | `getUser(id)` | `GET /users/{id}` | | `findUserByEmail(email)` | `GET /users/lookup?email=` → `{ user }` | | `createUser(input)` | `POST /users` → `{ user }` | | `updateUser(id, input)` | `PATCH /users/{id}` → `{ user }` | | `deleteUser(id)` | `DELETE /users/{id}` | | `blockUser(id, blocked = true)` | `PATCH /users/{id}` with `{ blocked }` | | `createLoginLink(id, { returnTo?, ttlSeconds? })` | `POST /users/{id}/login-link` → `{ url, expires_at }` | | `revokeUserSessions(id)` | `DELETE /users/{id}/sessions` → `{ revoked }` | Errors are `NineError` with the API's `error` as `code` and the HTTP `status`. For endpoints without a method (import, sessions), call the [Management API](https://9in.io/docs/reference/management-api/) directly. ## verifyWebhook ```ts verifyWebhook(secret: string | string[], request: Request, options?: { toleranceSeconds?: number; path?: string }): Promise ``` Checks `9in-signature` against the raw body and returns the parsed event, or throws `NineError("invalid_signature")`. Several secrets are accepted during rotation. `path` overrides the request path when a proxy rewrote it. It reads the body: pass `request.clone()` to read it again. `WebhookEvent`: `{ id, type, createdAt, tenant, environment, data }`, with `data.userId` and `data.user` for user events. ## processEventOnce ```ts processEventOnce(store: EventStore, eventId: string, handler: () => T | Promise, options?): Promise> ``` Runs `handler` once per event id: `{ status: "processed", result }`, `{ status: "duplicate" }` or `{ status: "in_progress" }` (answer 409 so 9in retries). If the handler throws, the id is released and the error rethrown. Options: `ttlSeconds` (default 3 days), `lockSeconds` (default 300), `prefix`. `EventStore` is `{ add(key, value, ttlSeconds): Promise (atomic), get, set, delete }`. `MemoryEventStore` implements it for one process. ## adapterHandler ```ts adapterHandler(handlers: AdapterHandlers): (request: Request) => Promise ``` Implements the [adapter protocol](https://9in.io/docs/reference/adapter-protocol/): verifies requests, routes `/authenticate`, `/user` and `/event`, and signs responses. | Handler | | |---|---| | `secret` | string or string[] (rotation) | | `authenticate(identifier, password)` | Return an `AdapterUser`, `"invalid"` or `"blocked"` | | `findUser(query)` | `{ id }` or `{ identifier }` → `AdapterUser` or `null` | | `onEvent(event)` | Optional: `otp.requested`, `user.updated`, `user.password_changed` | | `toleranceSeconds` | Default 300 | `signPayload(secret, method, path, body)` and `verifySignature(secret, header, method, path, body, toleranceSeconds?)` expose the signature scheme for other frameworks. ## NineError codes | Code | Meaning | |---|---| | `login_required` | No session: call `login()` | | `invalid_state` | The callback doesn't match a login in progress (another tab, a replay) | | `missing_code` | The callback URL has no code | | `invalid_issuer` | The callback's `iss` isn't your issuer | | `invalid_id_token` | The ID token failed validation | | `invalid_token` | An access token failed verification | | `insufficient_scope` | A required scope is missing | | `insecure_issuer` | The issuer isn't https (and isn't localhost) | | `discovery_failed`, `jwks_unavailable`, `userinfo_failed` | 9in couldn't be reached or answered unexpectedly | | `invalid_signature`, `invalid_payload`, `invalid_event` | A webhook or adapter request failed verification | | `invalid_options` | A verifier was created without what its mode needs | | OAuth errors | `access_denied`, `invalid_grant`… from the authorization server, passed through | --- # Webhook events > Every webhook event type, when it is sent, and the data it carries. Source: https://9in.io/docs/reference/webhook-events/ Every webhook is a `POST` with this JSON body (JSON Schema: [/schemas/webhook-event.json](https://9in.io/schemas/webhook-event.json)): ```json { "id": "evt_…", "type": "user.created", "createdAt": "2026-11-09T08:00:00.000Z", "tenant": "tnt_…", "environment": "production", "data": { … } } ``` For events about a user, `data` also has `userId` and `user`, a snapshot of the user as the [Management API](https://9in.io/docs/reference/management-api/#the-user-object) returns it (`null` once deleted). Headers: `9in-signature`, `9in-event-id`, `9in-event-type`, `9in-delivery-id`. How to verify and process them: [Webhooks](https://9in.io/docs/webhooks/). ## Users | Event | When | `data` (besides `userId`, `user`) | |---|---|---| | `user.created` | A user was created: sign-up, first social or enterprise sign-in, the console or the API | | | `user.updated` | Profile, identifiers, metadata or the blocked flag changed | `fields`: the names that changed | | `user.deleted` | The user was deleted | | | `user.password_changed` | A password was set, reset or changed | `reason`: `set`, `reset` or `import` | ## Sign-in | Event | When | `data` | |---|---|---| | `user.login` | Someone signed in | `method` (`pwd`, `otp`, `social`, `fed`, `link`, joined with `+` and `mfa`, e.g. `pwd+mfa`), `ip`, `client`, and `grant` for the password grant | | `user.login_failed` | A sign-in failed | `reason` (e.g. `invalid_credentials`, `mfa_invalid_code`), `method`, `ip`, `client`; `userId` is null | | `user.sessions_revoked` | All of a user's sessions were ended (sign out everywhere, an admin, the API) | `count` | ## Sessions and tokens | Event | When | `data` | |---|---|---| | `session.revoked` | One session ended (sign-out, the account page, an admin, the API) | `sid` | | `token.revoked` | A refresh or access token was revoked | `type` (`refresh_token` or `access_token`), `clientId`, `sid`, and `jti` for access tokens | APIs using [hybrid verification](https://9in.io/docs/protecting-apis/#hybrid-recommended) pass these to `verifier.handleEvent()`. ## Two-factor | Event | When | `data` | |---|---|---| | `user.mfa_enabled` | The user set up an authenticator app | | | `user.mfa_disabled` | Two-factor was turned off | `by`: `user` or `admin` | ## Testing | Event | When | |---|---| | `webhook.test` | You pressed **Send test** on an endpoint in the console | Events are delivered at least once and not necessarily in order; the same event `id` repeats on retries and redeliveries. --- # Adapter protocol > The three signed endpoints your user database implements, with request and response formats and the signature scheme. Source: https://9in.io/docs/reference/adapter-protocol/ The adapter protocol (version 2) is how 9in talks to [your own user database](https://9in.io/docs/own-database/). You implement three endpoints under a base URL; 9in calls them with signed JSON `POST` requests and expects signed responses. JSON Schemas: [user](https://9in.io/schemas/adapter-user.json), [/authenticate request](https://9in.io/schemas/adapter-authenticate-request.json), [/user request](https://9in.io/schemas/adapter-user-request.json), [event](https://9in.io/schemas/adapter-event.json). ## Requests | Header | | |---|---| | `Content-Type` | `application/json` | | `9in-signature` | `t=,v1=` | | `9in-request-id` | A unique id for this request | The signature covers `.POST..` with the shared secret from the console. Reject requests more than 5 minutes off, or with a wrong signature (compare in constant time). ## Responses Sign every response with your own `9in-signature` header, computed the same way over `.POST.#.`, so a response can't be reused for another request. 9in rejects unsigned or wrongly signed responses. 9in waits up to the configured timeout (3 seconds by default). ## POST /authenticate Check a password. ```json { "identifier": { "type": "email", "value": "jane@acme.com" }, "password": "…" } ``` `type` is `email`, `username` or `phone`. | Status | Body | |---|---| | 200 | `{ "user": }` | | 401 | `{ "error": "invalid_credentials" }` (wrong password or unknown user: don't tell them apart) | | 403 | `{ "error": "blocked" }` | ## POST /user Look a user up. ```json { "id": "42" } ``` or ```json { "identifier": { "type": "phone", "value": "+447700900123" } } ``` | Status | Body | |---|---| | 200 | `{ "user": }` | | 404 | `{ "error": "not_found" }` | ## POST /event ```json { "id": "evt_…", "type": "otp.requested", "createdAt": "2026-11-09T08:00:00.000Z", "data": { … } } ``` Answer any 2xx (204 is fine). | Type | `data` | |---|---| | `otp.requested` | `userId`, `channel` (`email` or `sms`), `to`, `code`, `purpose` (`login` or `password_reset`), `expiresAt` (ISO 8601) | | `user.updated` | `userId`, `fields` | | `user.password_changed` | `userId`, `fields` | `userId` is your id. Passwords are never sent. ## The user object | Field | Type | | |---|---|---| | `id` | string, required | Your user id (up to 256 characters) | | `email` | string or null | | | `email_verified` | boolean or null | | | `phone_number` | string or null | E.164 | | `phone_number_verified` | boolean or null | | | `username` | string or null | | | `name`, `given_name`, `family_name` | string or null | | | `picture` | string or null | https URL | | `locale`, `zoneinfo` | string or null | | | `birthdate` | string or null | `YYYY-MM-DD` | | `blocked` | boolean or null | `true` refuses sign-in | | `metadata` | object or null | Stored on the 9in user | Unknown fields are ignored. --- # Theme reference > Pages, partials, template variables, helpers, manifest.json options and security settings for custom themes. Source: https://9in.io/docs/reference/themes/ A theme is a set of Handlebars templates plus `manifest.json`. Templates run in a sandboxed engine: logic-less, whitelisted helpers only, no code execution. For an introduction see [Themes](https://9in.io/docs/themes/). ## Files | File | | |---|---| | `layout` | The page shell. Defines named blocks with `{{#component name}}…{{/component}}` and places them with `{{#render name}}{{/render}}` | | `login`, `registration`, `otp-options`, `otp-verification`, `password-recovery`, `password-reset`, `authorization`, `account`, `logout`, `error`, `lost`, `redirect`, `device`, `mfa`, `mfa-enroll` | One partial per page. Pages a theme doesn't include come from the default theme | | Other partials | Any `{{> name}}` you use, e.g. `field-input` | | `manifest.json` | Options and security settings | Limits: 60 templates, 256 KB each, 2 MB in total; names use `a-z`, `0-9`, `-`, `_`. Uploaded files (CSS, scripts, fonts, images): 2 MB each, 100 per tenant, referenced as `{{ assets_url }}`. ## Template data Available on every page: | Variable | | |---|---| | `$` | Translations of the page's language: `{{ $.login.title }}`. Placeholders: `{{fmt $.login.continue_with provider=name}}` | | `theme` | The theme's option values: `{{ theme.button_color }}` (images are ready-to-use URLs, or `""`) | | `lang` | `{ code, name, direction, rtl }` | | `languages` | `[{ code, name, url, active }]` when the tenant has several | | `application` | `{ name, logo, alternative_logo, icon, env, sandbox, login_url, logout_url, logout_everywhere_url, password_recovery_url, register_url, policy_url, tos_url, support_url }` | | `user` | When signed in: `{ id, established, display_name, initial, name, email, picture, profile }`, else null | | `client` | On the consent page: `{ id, name, icon, tos, privacy_policy }` | | `page` | `{ name, : true }`, e.g. `{{#if page.login}}` | | `errors`, `notices` | Arrays of messages to show | | `csrf` | `{ key, token }`: put both in every form (the default theme's `csrf-fields` component does) | | `tx` | The sign-in transaction id: keep it in forms | | `csp_nonce` | Put `nonce="{{ csp_nonce }}"` on every `