Reader sign-in: the API
How a reader of a space signs in: a registry of enabled providers, configured once for an organization and optionally overridden on a single space.
Console: Organization → Reader sign-in, and Space → Reader sign-in for an override.
Address | What it is |
|---|---|
| The org registry — the default for every space |
| What to register at your IdP for the whole org |
| One space's override (+ its same-origin endpoints) |
| The same registration values, per space |
| What the reader surface resolves to (no secrets) |
| Sign a reader out on that host |
All the admin endpoints need the admin token scope: identity configuration is not something a read-only token sees any of.
Secrets: write-only, and never lost by omission
A read never returns a secret's value. In its place you get <key>Set: true (clientSecretSet, sharedSecretSet, secretSet, secretKeySet, bindPasswordSet, outboundSync.scimBearerTokenSet), and every provider carries secretSet: true|false — whether a secret is stored for it.
On a write, a secret key that is absent, empty, or a redaction mask (********, ••••abcd, [REDACTED]) keeps the stored value, and the …Set / secretSet markers are ignored. So a GET can be sent back verbatim, and a save that leaves the secret out does not erase it. A provider in the body is matched to its stored self by method + order, then by method alone when only one of that method is stored — reordering the list moves secrets with their providers. The only way to remove a secret is to say so: PATCH it to null.
The same rule holds on every configuration endpoint that hides a secret on read: Cloudflare, mail, AI and embedding keys, captcha, SMS, DeepL, Discourse Connect, SSO, SIEM, consent webhooks, SDK registry tokens, extensions, group mailboxes, status-monitor headers, and a space's settings (including the legacy visitorAuth).
PATCH: change part, keep the rest
PUT replaces the registry. PATCH merges into it:
a top-level key that is absent is kept,
nulldeletes it, a value sets it;outboundSyncmerges key by key (nullremoves the whole block);each
providersentry is matched bymethod— plusorderwhen more than one provider of that method exists, otherwise a422asks for it; an entry that matches nothing is added (it must then be complete);remove: truedrops the match;a matched provider's
configmerges key by key,nulldeleting a key;providers you do not mention are untouched.
PATCH /orgs/{orgId}/reader-auth
{ "providers": [{ "method": "oidc", "label": "Sign in with Acme" }],
"signOutUrl": "https://app.acme.com/logout" }The merged result is validated exactly as a PUT is. On a space with no override, PATCH starts from an empty override, not a copy of the org's.
What to register at your IdP
…/reader-auth/setup?method=oidc answers it. The redirect URI is a single platform-wide address — it carries no org and no space, because the signed state parameter carries the org, the space and the return path:
https://<your-forumulate-host>/api/public/visitor-oidc/callbackRegister it exactly: no trailing slash, no query string. The endpoint also returns the scopes we request, the requiredConfig keys for that method (taken from the same validator the PUT enforces, so the two cannot disagree), and oneOfConfig for a method like external_jwt that needs either a jwksUrl or a sharedSecret.
Ask it at org level when you are configuring sign-in once for the whole organization — that is the recommended setup, and you should not have to read the redirect URI off an arbitrary space and assume it generalises. The org and space answers differ only in scope and startUrl: a reader always begins on a space, so the org-level startUrl names a literal {spaceId} placeholder rather than a space we picked for you.
Overriding one space, and removing the override
An override replaces the org's provider list for that space; it is not merged with it.
An override with an empty providers array does not disable sign-in — it inherits the org defaults, exactly as having no override does. A space cannot currently override the org with no sign-in methods at all. To remove an override, use
DELETE /orgs/{orgId}/spaces/{spaceId}/reader-authwhich deletes the space-level record and is idempotent: a space with no override is already in the state you asked for, so it answers 200, never 404.
Every read and write of a space override reports both facts, so you never have to infer which one you are in:
Field | Meaning |
|---|---|
| A space-level record is stored (even one with no providers) |
| Readers of this space get the org's sign-in methods |
PUT reports them too, so a save that does not actually govern readers says so at the moment you make it rather than on a later read.
Your own sign-in page (signInUrl)
Set signInUrl (https) on the org or a space and the reader sign-in surface shows one Sign in to continue link to your page instead of our provider buttons. We append these query parameters:
Parameter | Value |
|---|---|
| The absolute URL of the page the reader was on. Always set — it replaces any |
| The space's slug. |
| The space's id (a UUID). |
Encoding. Each is one ordinary query parameter, percent-encoded with the application/x-www-form-urlencoded rules (URLSearchParams): returnTo's own ?, &, = and # arrive encoded, so url.searchParams.get('returnTo') (or your framework's equivalent) hands back the exact page address — decode once. Parameters already in your signInUrl are kept, and the new ones go before any #fragment in it.
Length. returnTo is at most 2048 characters, the same cap every reader-session door enforces (the JWT redeem and visitor-oidc/start refuse a longer one with 422). A page address longer than that is sent without its query string and fragment; if it is still too long, as its origin. Size your storage for 2048.
space and spaceId are added only when your URL does not already carry them. With reader auth configured once for the org, one page serves every space: use spaceId to send the reader on to /api/public/orgs/{orgId}/spaces/{spaceId}/visitor-oidc/start?method=…&returnTo=… (or your own flow, such as an external JWT), then back to returnTo. You never have to work the space out of the returnTo path.
Same-origin endpoints for each address
A reader session is a set of host-only cookies, so anything that sets or clears one — redeeming an external JWT, signing out — must be called on the host the reader is reading. On a custom domain that is the same-origin API under the space's mount, https://<host><mount>/_api/…; on our own reader addresses it is https://forumulate.com/api/….
GET /orgs/{orgId}/spaces/{spaceId}/reader-auth answers them, per address, custom domain first:
"readerEndpoints": [
{ "url": "https://acme.com/docs", "kind": "domain", "primary": true,
"apiBase": "https://acme.com/docs/_api",
"jwtRedeemUrl": "https://acme.com/docs/_api/public/orgs/{orgId}/spaces/{spaceId}/reader-auth/jwt",
"signOutUrl": "https://acme.com/docs/_api/public/reader/sign-out" },
{ "url": "https://forumulate.com/p/acme/docs", "kind": "platform", "primary": false, … }
]kind is domain (the space's own domain), site (a site domain serving the space at /<space slug>) or platform. The per-space …/reader-auth/setup returns the same list, and a domain's GET /orgs/{orgId}/spaces/{spaceId}/domains/{domainId}/setup returns the one entry for that domain as readerEndpoints.
Signing out
From your app: POST …/public/reader/sign-out
POST https://acme.com/docs/_api/public/reader/sign-outRevokes the reader's session and clears every reader cookie on that host (the table below). Call it from a page on the reader's host — fetch(url, { method: 'POST', credentials: 'include' }) — because a host-only cookie can only be cleared by a response from its own host; a server-to-server call clears nothing in anybody's browser. The body is optional:
Field | Purpose |
|---|---|
| With |
| |
| Where to land afterwards; checked against the space's addresses (≤ 2048 chars), like sign-in. |
It answers { "ok": true, "cleared": ["forumulate_session", "fm_signed_in", "cflx_vis_…"], "redirectTo": "…" | null }. A plain HTML <form method="post"> is answered 303 to redirectTo instead.
From the docs: signOutUrl
Set signOutUrl (https) on the org or a space and Forumulate's own reader Sign out ends both sessions: it clears every reader cookie (as above) and then sends the reader to your page with one parameter,
Parameter | Value |
|---|---|
| The absolute URL of the page they signed out from — encoded and capped exactly as for |
End your own session there, then redirect to returnTo (or wherever you like). Without signOutUrl the reader simply stays on the page, signed out.
Reader cookies
All are set host-only (no Domain), Path=/, SameSite=Lax, and Secure in production. "Cleared" means POST …/reader/sign-out expires it.
Cookie | What it is | Lifetime | Cleared |
|---|---|---|---|
| The visitor pass for one space (password, JWT, OIDC or any provider). | 12 hours | yes — every one on the host |
| The same pass for every space of an org that shares one reader identity. | 12 hours | yes — every one on the host |
| The reader's member session (accounts, posting, | 30 days | yes |
|
| 30 days | yes |
| Atlas session cookies, on Forumulate hosts only ( | 60 s / 7 days | yes, on Forumulate hosts only |
| A private-space preview link's grant. Not a sign-in. | 30 days | no |
| A pseudonymous legal-acceptance record. Not a sign-in. | 1 year | no |
| The reader's language choice. A preference. | — | no |
On a custom domain we never touch a cookie named __session: your own app may use that name.
What runs in the browser
Nothing of ours. A reader signs in through a server-side redirect (…/visitor-oidc/start?method=… → your IdP → our callback), and the resulting reader session is a host-only cookie reached through the same-origin <mount>/_api/… proxy on your domain. There is no Forumulate identity SDK on a custom-domain page — our own staff SDK is mounted only on our origins, because its session cookies are ours and a browser would not send them to your domain anyway.
Published. https://forumulate.com/guides/reader-sign-in-api, in the
guidesspace's "Reader sign-in" collection, beside the existing setup, methods, domain and sync pages. The slug is deliberately notreader-sign-in: that page already exists and this is the API side of it. Re-import after editing.