Reader sign-in: the API

Embed
Preview of the card
<a href="https://forumulate.com/guides/reader-sign-in-api"><img src="https://forumulate.com/guides/_api/public/orgs/046a9bc0-d530-43ae-9bae-af7945008cda/spaces/36aa4df0-1e95-4cef-8ae9-795169441345/pages/e318817c-9467-4c04-82ec-c815883c0693/social-card.svg?v=3e7ae33eec8b" alt="Reader sign-in: the API" width="600" height="315"></a>

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

GET/PUT/PATCH /orgs/{orgId}/reader-auth

The org registry — the default for every space

GET /orgs/{orgId}/reader-auth/setup

What to register at your IdP for the whole org

GET/PUT/PATCH/DELETE /orgs/{orgId}/spaces/{spaceId}/reader-auth

One space's override (+ its same-origin endpoints)

GET /orgs/{orgId}/spaces/{spaceId}/reader-auth/setup

The same registration values, per space

GET /public/orgs/{orgId}/spaces/{spaceId}/reader-auth

What the reader surface resolves to (no secrets)

POST <mount>/_api/public/reader/sign-out

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, null deletes it, a value sets it;

  • outboundSync merges key by key (null removes the whole block);

  • each providers entry is matched by method — plus order when more than one provider of that method exists, otherwise a 422 asks for it; an entry that matches nothing is added (it must then be complete); remove: true drops the match;

  • a matched provider's config merges key by key, null deleting 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/callback

Register 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-auth

which 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

hasOverride

A space-level record is stored (even one with no providers)

inherits

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

returnTo

The absolute URL of the page the reader was on. Always set — it replaces any returnTo already in your URL.

space

The space's slug.

spaceId

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-out

Revokes 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

orgId

With spaceId: answer where to go next.

spaceId

returnTo

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

returnTo

The absolute URL of the page they signed out from — encoded and capped exactly as for signInUrl, replacing any returnTo in your URL.

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

cflx_vis_<spaceId>

The visitor pass for one space (password, JWT, OIDC or any provider). HttpOnly.

12 hours

yes — every one on the host

cflx_vis_org_<orgId>

The same pass for every space of an org that shares one reader identity. HttpOnly.

12 hours

yes — every one on the host

forumulate_session

The reader's member session (accounts, posting, /me). HttpOnly. Revoked server-side too.

30 days

yes

fm_signed_in

1 and nothing else — lets the cached page render signed-in chrome before paint. Readable.

30 days

yes

__session, __atlas_rt, __atlas_uat

Atlas session cookies, on Forumulate hosts only (Domain=.forumulate.com).

60 s / 7 days

yes, on Forumulate hosts only

cf_share_<spaceId>

A private-space preview link's grant. Not a sign-in.

30 days

no

cflx_racc_<spaceId> / cflx_racc_org_<orgId>

A pseudonymous legal-acceptance record. Not a sign-in.

1 year

no

forumulate_lang

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 guides space's "Reader sign-in" collection, beside the existing setup, methods, domain and sync pages. The slug is deliberately not reader-sign-in: that page already exists and this is the API side of it. Re-import after editing.

The Forumulate teamUpdated