Reader sign-in with your app

Embed
Preview of the card
<a href="https://forumulate.com/guides/reader-sign-in-with-your-app"><img src="https://forumulate.com/guides/_api/public/orgs/046a9bc0-d530-43ae-9bae-af7945008cda/spaces/36aa4df0-1e95-4cef-8ae9-795169441345/pages/53919601-682f-4053-ad0e-80da704f1c06/social-card.svg?v=3e7ae33eec8b" alt="Reader sign-in with your app" width="600" height="315"></a>

Integrating a product with Forumulate? Already built for you names what exists, with the one call for each.

Your product already signs its users in. This guide makes those same users signed in on your Forumulate docs, forum and blog, with no second password and no hand-off route of your own to maintain.

There are two ways to do it, and the first is the one we recommend:

  1. Use your identity provider. Your product hands us the token your IdP already mints (Atlas, Auth0, Clerk, Okta, Cognito, Keycloak, Supabase, Firebase, Entra, Google, WorkOS, or any OpenID Connect provider). We verify it against the IdP's published keys. You hold no signing secret for us.

  2. No IdP? Sign the token yourself with a shared secret (external_jwt). This is the fallback, and the older path. It is documented in full in Sign readers in from your own app with a JWT.

Both arrive at the same redeem address and give the reader the same cookies.

How the hand-off works

 Reader's browser          Your app / IdP                 Forumulate (on the reader's host)
       │                         │                                   │
       │  click "Docs"           │                                   │
       │────────────────────────>│                                   │
       │                         │ get a fresh token from your IdP   │
       │                         │ (aud = forumulate:<orgId>)        │
       │  302 to jwtRedeemUrl?jwt=…&returnTo=…                       │
       │<────────────────────────│                                   │
       │  GET jwtRedeemUrl ─────────────────────────────────────────>│
       │                         │      iss → discovery → jwks_uri   │
       │                         │      verify signature, iss, aud,  │
       │                         │      exp, token_use, sub          │
       │  302 to returnTo, Set-Cookie (host-only)                    │
       │<────────────────────────────────────────────────────────────│

The token is checked like this, for every provider:

Check

Rule

Signature

Asymmetric only: RS*, PS*, ES*, EdDSA. An HS* or none token is refused (bad_algorithm).

Keys

issuer → /.well-known/openid-configuration → jwks_uri. Cached with its ETag; refetched once when a kid is unknown.

iss

Must equal the resolved issuer. A trailing slash is ignored, so Auth0's https://acme.us.auth0.com/ matches either way.

aud

Must contain the audience. The default is forumulate:<orgId>: a token minted for us, and for nobody else.

exp

Required, and no further out than the provider's maximum lifetime (Atlas: 120 seconds).

Clock skew

30 seconds either way on exp, nbf and iat.

token_use

Checked when the preset or config names one (Atlas: template; Cognito: id or access).

sub

Required. It is the reader's identity key.

Why the audience matters: your IdP mints tokens for several products. A token it minted for your chat widget (aud: chatly:…), or for a different Forumulate organization, is refused with wrong_audience, so a token that leaks from one place cannot sign a reader in somewhere else.

Any link on a reader page that goes to a sign-in address carries the page the reader was on, so signing in lands them back there. That covers a header button, a footer link, a link in a page body, and our own controls. A sign-in address is a path of /login, /signin, /sign-in, /sso or /atlas-signin, or any link containing {returnTo}.

The link as you wrote it

What a reader on /community/t/42 follows

/login

/login?returnTo=%2Fcommunity%2Ft%2F42

/login?return_to= (an empty parameter)

/login?return_to=%2Fcommunity%2Ft%2F42

https://app.example.com/auth?next={returnTo}

{returnTo} replaced by the page's full address

/login?next=/pricing (already filled)

left as written

A login on the same origin gets the page's path, which an open-redirect check accepts. One on another origin gets the full address. The empty-parameter form works for returnTo, return_to, next, redirect, redirect_to, redirect_uri and return, so name the one your login page reads.

1. Configure the provider

PUT /orgs/{orgId}/reader-auth
{
  "providers": [
    {
      "method": "oidc",
      "order": 0,
      "config": { "preset": "auth0", "domain": "acme.us.auth0.com" }
    }
  ]
}

That covers every space in the organization. PUT /orgs/{orgId}/spaces/{spaceId}/reader-auth does the same for one space and overrides the organization. PATCH changes one field and keeps the rest.

A preset only fills in discovery and claim mapping. The verification code is the same for every IdP, so any provider not listed here works as the generic oidc preset with its issuer.

Preset

Method

Config you give

Default audience

token_use

Max token lifetime

atlas

atlas

instanceId (or frontendApi for a custom domain)

forumulate:<orgId>

template

120 s

auth0

oidc

domain

forumulate:<orgId>

—

3600 s

clerk

oidc

frontendApi

forumulate:<orgId>

—

600 s

okta

oidc

domain, authorizationServerId (default default, org for the org server)

forumulate:<orgId>

—

3600 s

cognito

oidc

region, userPoolId, clientId

the app client id

id

3600 s

keycloak

oidc

baseUrl, realm

forumulate:<orgId>

—

3600 s

entra

oidc

tenantId, clientId

the application id

—

5400 s

google

oidc

clientId

the client id

—

3600 s

firebase

oidc

projectId

the project id

—

3600 s

supabase

oidc

projectRef

authenticated

—

3600 s

workos

oidc

clientId

forumulate:<orgId>

—

600 s

oidc

oidc

issuer

forumulate:<orgId>

—

3600 s

The oidc method with preset set is the general form. atlas is shorthand for atlas_byo and is stored and read back as atlas_byo.

Every preset value can be overridden by setting the key of the same name in config. Vendors move things: custom domains, new key formats, namespaced claims. A preset you cannot correct would strand you on the day that happens.

Key

Overrides

issuer

The issuer tokens must carry. Discovery is read at <issuer>/.well-known/openid-configuration.

discoveryUrl

Where the discovery document is read, when it is not under the issuer.

jwksUri

The key set, used directly instead of discovery's jwks_uri.

audience

The audience the token must carry.

audienceClaim

The claim the audience is read from, when it is not aud (Cognito access tokens use client_id).

tokenUse

The token_use the token must carry.

maxTokenTtlSec

The longest lifetime (exp minus now) a token may have. Capped at one day.

claimEmail, claimName, claimExt, claimPrincipal, claimSid

The claim each fact is read from. Dotted paths work: user_metadata.full_name. Use these for Auth0's namespaced custom claims.

clientId

Only needed if you also want a "Sign in" button that redirects to the IdP.

Check what it resolved to

GET /orgs/{orgId}/reader-auth (and the space-level read) returns each IdP provider with a read-only resolved block:

"resolved": {
  "preset": "atlas",
  "issuer": "https://accounts.yourcompany.com",
  "discoveryUrl": "https://api.atlasauth.net/instances/ins_2abc/.well-known/openid-configuration",
  "jwksUri": "https://api.atlasauth.net/instances/ins_2abc/.well-known/jwks.json",
  "audience": "forumulate:07f432f4-…",
  "signInUrl": "https://accounts.yourcompany.com/hosted/sign-in",
  "tokenUse": "template",
  "maxTokenTtlSec": 120,
  "backchannelLogoutUrl": "https://forumulate.com/api/public/orgs/07f432f4-…/oidc/backchannel-logout"
}

When discovery fails, resolved.error says why. You find out at configuration time, not when the first reader is refused. A PUT that sends a read back verbatim is fine: resolved is ignored on write.

GET /orgs/{orgId}/integration-health (reader_auth.configured) probes the same discovery document, and only for a provider that verifies through it. An external_jwt provider that holds a sharedSecret, or names its own jwksUrl, never reads discovery, so its issuer (often just your app's origin, with no /.well-known/openid-configuration) is not probed and the row reads configured: true once the config is complete.

Your own sign-in page (signInUrl)

signInUrl (org or space, https) swaps the provider buttons for one "Sign in to continue" link to your page. The target must read returnTo: we append ?returnTo=<the page the reader was on>&space=<slug>&spaceId=<id>, and your page sends the reader back to returnTo after sign-in. A page that ignores it signs the reader in and leaves them on your home.

https://app.yourcompany.com/sign-in
  → https://app.yourcompany.com/sign-in?returnTo=https%3A%2F%2Fdocs.yourcompany.com%2Fguides%2Fintro&space=guides&spaceId=…

If your page wants the address under another name or in its path, put {returnTo} where it goes (https://app.yourcompany.com/login?next={returnTo}, https://app.yourcompany.com/in/{returnTo}): it is replaced with the URL-encoded address and no returnTo is appended. A manifest plan warns when a signInUrl that is changing has no {returnTo}, and every save answers warnings[]. An Atlas provider keeps its own hosted page and is not replaced by signInUrl. Moving off a page: Change a space's sign-in method.

2. Recipes

Each recipe has two parts. The first is the provider config. The second is how to get a token with the right audience from that IdP. Then redeem it.

Atlas

{ "method": "atlas", "order": 0, "config": { "instanceId": "ins_…", "audience": "forumulate:<orgId>" } }

We read the issuer and keys from Atlas's per-instance discovery document, so the instance id is all you configure. That works whether or not the instance has a custom frontend API domain. Once, on your Atlas instance, enable the Forumulate integration with PUT /v1/instance/integrations/forumulate. After that, your front end asks Atlas for a reader token whenever a signed-in user opens the docs:

const { token } = await fetch('https://<your-atlas-frontend-api>/v1/client/forumulate/reader_token', {
  credentials: 'include',
}).then((r) => r.json());
// redeem it: see section 3

The token carries aud: forumulate:<orgId>, token_use: template, lives 120 seconds, and includes ext (your account id) and principal.

If your product uses Atlas, this is the recommended method — it replaces a hand-rolled external_jwt route of your own. Three places say so, from the same rule, whenever your organization uses Atlas (an Atlas issuer or JWKS on an external_jwt provider, or an Atlas instance linked to the organization) but signs readers in some other way:

  • GET /orgs/{orgId}/reader-auth answers recommendations: [{code: "reader_auth.use_atlas", message, docs, patch, manifest, relink}]. patch is the PATCH /orgs/{orgId}/reader-auth body that switches; relink is the member relink to run first, so nobody comes back as a stranger.

  • A manifest plan (PUT /orgs/{orgId}/manifest, version: 2) answers the same recommendations when the document says nothing about reader sign-in. The manifest spelling is one line:

    { "version": 2, "readerAuth": "atlas" }

    "atlas" takes the Atlas instance linked to the organization; name one with {"method": "atlas", "instanceId": "ins_…"} (or frontendApi for a custom domain). It expands into an organization-level reader_auth entry, so it cannot be combined with one.

  • forumulate doctor --site <site> --org <org> reports it as the reader-auth-atlas check.

When recommendations is []. The rule reads only your organization's reader configuration, never its name or how your staff sign in to the console (every console sign-in goes through Atlas, so that would say nothing). It is empty, by design, when:

  • an atlas (atlas_byo) provider is already configured — there is nothing left to recommend;

  • your external_jwt tokens are minted by your own server (its own issuer, an HS256 secret, or a JWKS that is not on atlasauth.net or an Atlas /instances/ins_… path), and no Atlas instance is linked. That is a legitimate way to sign readers in from any app, and we cannot tell it is backed by Atlas. Once your app moves to Atlas, either point the provider's issuer/jwksUrl at your Atlas instance or link the instance (a policy source link with consumer: "atlas") and the recommendation appears; or skip it and switch directly with {"version": 2, "readerAuth": {"method": "atlas", "instanceId": "ins_…"}}.

Consent follows the reader. If your site uses Cookie Munch with your Atlas instance as its identity issuer, a reader signed in here is identified to it as the same person as on your product's pages. Add the instance's secret key (scope sessions:write) to this provider as config.secretKey. The key is write-only, and the same key registers the sign-out receiver. Then enable the cookiemunch integration on the instance. Our server uses the key to ask Atlas for a token for the reader's own session. Without the key, or for any other IdP, Forumulate can sign the token itself, acting for your IdP, once your Cookie Munch site lists us: GET /orgs/{orgId}/consent/identity returns the entries to paste. See Identity.

Auth0

{ "method": "oidc", "order": 0, "config": { "preset": "auth0", "domain": "acme.us.auth0.com" } }

In Auth0, create an API whose identifier is forumulate:<orgId>. Then request an access token for that audience: getAccessTokenSilently({ authorizationParams: { audience: 'forumulate:<orgId>' } }). Auth0 requires custom claims to be namespaced, so if an Action adds ext, set claimExt to that namespaced name, for example "https://yourcompany.com/ext".

Clerk

{ "method": "oidc", "order": 0, "config": { "preset": "clerk", "frontendApi": "https://clerk.yourcompany.com" } }

Create a JWT template (say forumulate) with "aud": "forumulate:<orgId>" and the claims you want (email, name, ext). Get the token with await getToken({ template: 'forumulate' }).

Okta

{ "method": "oidc", "order": 0, "config": { "preset": "okta", "domain": "acme.okta.com", "authorizationServerId": "default" } }

On a custom authorization server, set the audience to forumulate:<orgId> and add email to its access token claims. Request an access token from that server. The org authorization server cannot carry a custom audience. If you must use it ("authorizationServerId": "org"), set audience to your client id and redeem the ID token instead.

Amazon Cognito

{ "method": "oidc", "order": 0, "config": { "preset": "cognito", "region": "eu-west-1", "userPoolId": "eu-west-1_AbCdEf123", "clientId": "1example23456789" } }

Cognito cannot put a custom audience in a token, so the audience is the app client id. Redeem the ID token as it is. To redeem an access token instead, set "tokenUse": "access". The audience is then read from client_id.

Keycloak

{ "method": "oidc", "order": 0, "config": { "preset": "keycloak", "baseUrl": "https://sso.yourcompany.com", "realm": "customers" } }

On the client, add an Audience protocol mapper with the included custom audience forumulate:<orgId>, added to the access token. Redeem the access token.

Supabase

{ "method": "oidc", "order": 0, "config": { "preset": "supabase", "projectRef": "abcdefghijklmnop" } }

Redeem the session's access_token as it is. Its audience is authenticated. The project must use asymmetric JWT signing keys. Tokens signed with the legacy shared JWT secret cannot be verified from a public key set.

Firebase Authentication

{ "method": "oidc", "order": 0, "config": { "preset": "firebase", "projectId": "acme-prod" } }

Redeem await auth.currentUser.getIdToken() as it is. Its audience is the project id.

Entra ID, Google, WorkOS, anything else

Provider

Config

Token to redeem

Entra ID

{"preset":"entra","tenantId":"…","clientId":"…"}

The ID token (audience = application id)

Google

{"preset":"google","clientId":"…apps.googleusercontent.com"}

The ID token from Google Identity Services

WorkOS

{"preset":"workos","clientId":"client_…"}

An AuthKit access token with aud: forumulate:<orgId>

Any OIDC IdP

{"preset":"oidc","issuer":"https://login.example.com"}

Any JWT it signs with aud: forumulate:<orgId> (or set audience)

3. Redeem the token

Redeeming works exactly as it does for external_jwt, at the same address. Use readerEndpoints[].jwtRedeemUrl from GET /orgs/{orgId}/spaces/{spaceId}/reader-auth, picking the entry whose url your returnTo is under:

https://yourcompany.com/docs/_api/public/orgs/{orgId}/spaces/{spaceId}/reader-auth/jwt

Redeem on the reader's own host. The cookies are host-only, so a redeem on forumulate.com signs the reader in on forumulate.com and nowhere else. The reasons are explained in Redeem it.

Shape

Request

Answer

Link

GET <jwtRedeemUrl>?jwt=<token>&returnTo=<url>

302 to returnTo, cookies set

Fetch

POST <jwtRedeemUrl> {"jwt": "…", "returnTo": "…"}

{"ok":true,"returnTo":"…","pending":false}

The endpoint reads the token's iss. If that issuer belongs to an IdP provider on the space, the token is verified against that IdP. Otherwise it is treated as external_jwt. To force one or the other, add "method" to the POST body ("atlas_byo", "oidc" or "external_jwt").

Get a fresh token for every redeem. Never cache one.

Who the reader is

Readers are keyed on (iss, sub), never on email. If a reader changes their email at the IdP, they come back as the same member. If someone types another person's address into a sign-up form, they do not get that person's account.

  • ext (your account id, for example merchant:<uuid>) and email are stored as attributes. The members list shows them, and GET /orgs/{orgId}/members?externalId= finds a member by them.

  • principal is recorded per session, and the member keeps every principal they have signed in as. One member may hold several: principals: ["merchant", "partner"].

  • A new (iss, sub) whose email already belongs to an account gets a separate reader account by default. Set the key reader.idp_email_collision to link_verified to link to that account instead, and only when the token says email_verified: true.

reader.idp_email_collision is resolved like every reader key: the space's override (identityPolicy.emailCollision on the space's reader-auth), then the org setting, then the default (separate).

externalId and externalProvider on a member

Each member row in GET /orgs/{orgId}/members carries externalId (the sub the reader signed in with), externalProvider, externalIssuer, ext and principals. externalProvider depends on how the reader arrived:

The reader arrived by

externalProvider

A token you signed (external_jwt)

external_jwt

A redirect sign-in (…/visitor-oidc/start)

The method: oidc, atlas_byo, google, microsoft, …

Your IdP's own token, redeemed at …/reader-auth/jwt

The IdP preset: atlas, auth0, clerk, okta, cognito, keycloak, entra, google, firebase, supabase, workos, or oidc for any other issuer

atlas and atlas_byo are the same provider: either word finds a reader stored under the other. Every value in the table is accepted wherever an externalProvider is taken.

GET /orgs/{orgId}/members?externalId=<sub or ext> finds the member. The rows are under members. With a Forumulate-Version header (or ?cursor=), the same array is also under data, and page is added. That is the rule for every list (see what the list endpoints answer). The two keys are kept because older scripts read members, while a generic client reads data on every list. Read (.data // .members) to work with either shape.

Account closure is one call, safe to retry:

DELETE /orgs/{orgId}/members?externalId=user_123&externalProvider=auth0   → { "removed": 1, "revoked": 2 }

It removes the member and signs the reader out on every host, your domain included; { "removed": 0, "revoked": 0 } means already gone. Pass &revokeSessions=false to keep the sessions. To sign a reader out WITHOUT removing them, call POST /orgs/{orgId}/reader-sessions/revoke with { "externalId": "user_123", "externalProvider": "auth0" }.

No IdP? Sign the token yourself

If your product has its own user table and no IdP, mint an HS256 token with a secret you share with us. Configure external_jwt:

{ "method": "external_jwt", "order": 0, "config": { "sharedSecret": "a-long-random-string", "clientId": "forumulate-docs", "issuer": "https://app.yourcompany.com" } }

The rules are given in full in the JWT guide. In short:

  • The algorithm is HS256 and pinned. Any other alg is refused.

  • The key is the UTF-8 bytes of the secret string as stored. It is not base64-decoded.

  • sub, email, iat and exp are required. aud must equal clientId and iss must equal issuer when those are configured.

  • iat may be at most 300 seconds old, with 30 seconds of skew either way. Expiry is checked separately, so mint a fresh token per redeem.

Node

With nothing but node:crypto:

import { createHmac, randomUUID } from 'node:crypto';

const b64url = (v) => Buffer.from(JSON.stringify(v)).toString('base64url');

export function readerToken(user, secret) {
  const now = Math.floor(Date.now() / 1000);
  const head = b64url({ alg: 'HS256', typ: 'JWT' });
  const body = b64url({
    sub: user.id,
    email: user.email,
    email_verified: true,
    name: user.name,
    aud: 'forumulate-docs',
    iss: 'https://app.yourcompany.com',
    iat: now,
    exp: now + 120,
    jti: randomUUID(),
  });
  const sig = createHmac('sha256', Buffer.from(secret, 'utf8')).update(`${head}.${body}`).digest('base64url');
  return `${head}.${body}.${sig}`;
}

Or with the helper, which also validates returnTo, caches the space's reader addresses and maps errors:

import { ReaderHandoff } from '@forumulate/reader';

const handoff = new ReaderHandoff({
  orgId: process.env.FORUMULATE_ORG_ID,
  spaceId: process.env.FORUMULATE_SPACE_ID,
  sharedSecret: process.env.FORUMULATE_READER_SECRET,
  issuer: 'https://app.yourcompany.com',
  audience: 'forumulate-docs',
});

app.get('/docs', async (req, res) => {
  const url = await handoff.redeemUrl({
    user: { sub: req.user.id, email: req.user.email, name: req.user.name },
    returnTo: `https://yourcompany.com/docs${req.query.path ?? ''}`,
  });
  res.redirect(url);
});

Python

import time, uuid, jwt  # PyJWT
from urllib.parse import urlencode

def reader_link(user, secret, redeem_url, return_to):
    now = int(time.time())
    token = jwt.encode(
        {
            "sub": user.id,
            "email": user.email,
            "email_verified": True,
            "name": user.name,
            "aud": "forumulate-docs",
            "iss": "https://app.yourcompany.com",
            "iat": now,
            "exp": now + 120,
            "jti": str(uuid.uuid4()),
        },
        secret.encode("utf-8"),  # the UTF-8 bytes of the secret, not base64-decoded
        algorithm="HS256",
    )
    return redeem_url + "?" + urlencode({"jwt": token, "returnTo": return_to})

Or with the Python port of the helper, forumulate-reader (standard library only, Python 3.8+), which does the same as the Node one:

pip install git+https://github.com/forumulate/forumulate-reader-python
import os
from flask import redirect, request
from forumulate_reader import ReaderHandoff, ReturnToError, map_error

handoff = ReaderHandoff(
    os.environ["FORUMULATE_ORG_ID"],
    os.environ["FORUMULATE_READER_SECRET"],
    space_id=os.environ["FORUMULATE_SPACE_ID"],
    issuer="https://app.yourcompany.com",
    audience="forumulate-docs",
    admin_token=os.environ["FORUMULATE_ADMIN_TOKEN"],  # or reader_endpoints=[...]
)

@app.get("/docs")
@login_required
def docs():
    try:
        url = handoff.redeem_url(
            {"sub": current_user.id, "email": current_user.email, "name": current_user.name},
            "https://yourcompany.com/docs" + request.args.get("path", ""),
        )
    except ReturnToError as err:
        return str(err), 400
    return redirect(url)

handoff.sign_out_cookies() answers the Set-Cookie lines for a same-host sign-out, handoff.sign_out_url(return_to) the address to POST to from anywhere else, and map_error(response.content) the advice for a refused redeem (.reason, .hint, .retry).

Go

The Go port, github.com/forumulate/forumulate-reader-go, has the same API (standard library only, Go 1.21+):

go get github.com/forumulate/forumulate-reader-go
import reader "github.com/forumulate/forumulate-reader-go"

handoff, err := reader.New(reader.Options{
	OrgID:        os.Getenv("FORUMULATE_ORG_ID"),
	SpaceID:      os.Getenv("FORUMULATE_SPACE_ID"),
	SharedSecret: os.Getenv("FORUMULATE_READER_SECRET"),
	Issuer:       "https://app.yourcompany.com",
	Audience:     "forumulate-docs",
	AdminToken:   os.Getenv("FORUMULATE_ADMIN_TOKEN"), // or ReaderEndpoints: []reader.Endpoint{…}
})
if err != nil {
	log.Fatal(err)
}

http.HandleFunc("/docs", func(w http.ResponseWriter, r *http.Request) {
	u := currentUser(r)
	link, err := handoff.RedeemURL(r.Context(), reader.RedeemInput{
		User:     reader.User{Sub: u.ID, Email: u.Email, Name: u.Name},
		ReturnTo: "https://yourcompany.com/docs" + r.URL.Query().Get("path"),
	})
	var bad *reader.ReturnToError
	if errors.As(err, &bad) {
		http.Error(w, bad.Message, http.StatusBadRequest)
		return
	} else if err != nil {
		http.Error(w, "docs sign-in unavailable", http.StatusBadGateway)
		return
	}
	http.Redirect(w, r, link, http.StatusFound)
})

handoff.SignOutCookies(), handoff.SignOutURL(ctx, returnTo, "") and reader.MapError(body) match the Python and Node calls. All three helpers are tested against one set of vectors, so they mint byte-identical claims.

returnTo rules

  • It must be an absolute https URL under one of the space's reader addresses: its verified custom domains, the sites it is served in, or its platform URL. Those are the readerEndpoints[].url values.

  • It may be at most 2048 characters.

  • We validate it again on redeem. An address outside that set is not an error. The reader lands on the space's primary address instead, which is your custom domain when you have one. That is what makes the redeem useless as an open redirect. It is also why a typo looks like "it ignored my parameter".

@forumulate/reader, and its Python and Go ports, refuse a bad returnTo before minting anything, so you see the mistake in your own logs.

Signing out, both ways

The reader signs out of your app. You can end their docs session too, and you never need our cookie names:

  • As a link. After your own sign-out, send the browser to signOutUrl?orgId=<org>&spaceId=<space>&returnTo=<url>, a GET. signOutUrl is readerEndpoints[].signOutUrl on the reader's host, and orgId/spaceId take the uuid or the slug. That request revokes the session, clears every reader cookie on that host, and answers 302 to returnTo. returnTo may be an address of the space, or an https URL on the origin of the space's reader-auth signInUrl or signOutUrl (your product). Anything else lands on the space's primary address, so the link cannot be used as an open redirect. See Signing out for the full rules.

  • From a page on the reader's host, call fetch(signOutUrl, { method: 'POST', credentials: 'include' }), where signOutUrl is readerEndpoints[].signOutUrl.

  • From your server, with no browser involved: POST /orgs/{orgId}/reader-sessions/revoke with { "externalId", "externalProvider" }. The pair is the one the members list reports (see Who the reader is). It ends every session of that reader on every host and answers { "revoked": n }.

  • Use OIDC back-channel logout, which works without the browser. Your IdP posts a logout token to:

    POST https://forumulate.com/api/public/orgs/{orgId}/oidc/backchannel-logout
    Content-Type: application/x-www-form-urlencoded
    
    logout_token=<JWT>

    (/api/public/orgs/{orgId}/atlas/backchannel-logout is the same receiver.) The logout token is verified against the issuer's JWKS. Its aud must be the provider's audience or client id, events must contain http://schemas.openid.net/event/backchannel-logout, it must not carry a nonce, and it must name a sid or a sub. We then end the matching reader sessions on the server. A sid ends that one session and a sub ends all of theirs. The reader's cookies stop working on their next request, even though we cannot reach their browser to clear them.

Most IdPs need the receiver URL entered on the client; it is resolved.backchannelLogoutUrl. POST /orgs/{orgId}/reader-auth/logout-receivers does it for you when the IdP allows it. It registers with Atlas, or with any IdP that supports RFC 7592 client updates when the provider has a registrationAccessToken. Otherwise it answers with the exact values to paste. GET on the same path lists the receivers and when each last received something.

The reader signs out of the docs. Set signOutUrl on the reader-auth config. After we clear our cookies, the reader is sent there with returnTo, so they are signed out of your app as well.

Lifecycle events

Sign-out is one event. Bans and deletions matter too. Point your IdP's user webhooks at:

POST https://forumulate.com/api/public/orgs/{orgId}/identity/events/{preset}

Here {preset} is atlas, clerk, workos, auth0, okta or generic. The signature is checked with the provider's webhookSecret, which is a write-only key on the reader-auth provider:

Scheme

Used by

Proof

standard_webhooks

Atlas, Clerk, generic

webhook-id / webhook-timestamp / webhook-signature (also svix-*, atlas-*): base64 HMAC-SHA256 of id.timestamp.body

workos

WorkOS

WorkOS-Signature: t=<ms>, v1=<hex HMAC-SHA256 of "t.body">

bearer

Auth0 log streams, Okta event hooks

Authorization: Bearer <secret>

hmac_sha256

anything else

X-Signature-256: sha256=<hex HMAC-SHA256 of the body>

Each vendor's events are mapped to five types:

Event

What we do

Key (space → org → default)

user.updated

Refresh the reader's email, name and ext.

—

user.banned

Suspend the reader in every space of the organization now, and end their sessions.

reader.idp_user_banned: suspend (default), revoke_sessions, ignore

user.unbanned

Lift that suspension.

—

user.deleted

Open a privacy-executor closure, honouring executeAfter so nothing goes before then.

reader.idp_user_deleted: closure (default), suspend, ignore; reader.idp_deletion_grace_days (default 0)

session.revoked

The same as a back-channel logout.

—

How userDeleted is fed for a plain oidc provider

Nothing feeds it by itself. OpenID Connect defines sign-out (back-channel logout) but no "user deleted" event, so a deletion reaches us only if your product sends one. Put a webhookSecret on the oidc provider (a write-only key on the reader-auth provider) and have your account-closure code POST the generic shape below to …/identity/events/generic, signed with that secret (default standard_webhooks; set webhookScheme to hmac_sha256 or bearer to sign another way). The event is matched to the provider by its signature, then to the reader by subject; then reader.idp_user_deleted decides (closure by default). Alternatively skip the event and remove the member directly with DELETE /orgs/{orgId}/members?externalId=…&externalProvider=oidc.

generic takes our own shape, signed with Standard Webhooks:

{ "id": "evt_1", "type": "user.banned", "subject": "user_2abc", "issuer": "https://login.example.com", "reason": "abuse" }

An Atlas instance can also deliver these to POST /atlas/webhooks, which applies them to atlas_byo readers. POST /atlas/webhooks?org=<orgId> is verified with that organization's Atlas provider webhookSecret.

An event for a subject we do not know still answers 200. Nothing is done for it, and the sender has no reason to retry.

readerAuth: atlas or oidc

Both are reader sign-in methods in the registry; atlas is stored and read back as atlas_byo (your own Atlas instance for your readers; it is in use, and not the Atlas that signs our staff in).

atlas (atlas_byo)

oidc

You give us

instanceId (issuer and keys come from Atlas's discovery)

issuer, client id (and secret), scopes

We store per reader

the (issuer, sub) identity, name, email, ext

the same (issuer, sub) identity, name, email, ext

Sign-out of the reader's session

session events, and back-channel logout registered for you

back-channel logout (RFC 7592 registration when the IdP allows it, else the values to paste)

Bans, deletions, profile changes

Atlas delivers them: POST /atlas/webhooks?org= or …/identity/events/atlas

you send them to …/identity/events/generic, or use an okta/auth0/clerk/workos preset

Deletion policy

reader.idp_user_deleted: closure (default), suspend, ignore

the same key

Every reader cookie is host-only (no Domain), Path=/, and set on the host that answered the redeem. All of them are strictly necessary.

Cookie

Flags

Lifetime

Purpose

forumulate_session

HttpOnly, Secure, Lax

30 days

The reader's member session.

fm_signed_in

Secure, Lax (readable)

session

Tells the page to render the signed-in header.

cflx_vis_org_<orgId>

HttpOnly, Secure, Lax

12 hours

Reader pass for the whole organization (org identity scope).

cflx_vis_<spaceId>

HttpOnly, Secure, Lax

12 hours

Reader pass for one space.

Errors

Every refusal carries a stable word in error.reason. Branch on that, not on the message.

{
  "error": {
    "code": "auth.token_invalid",
    "reason": "wrong_audience",
    "message": "That token was not accepted (wrong_audience): …"
  },
  "requestId": "…"
}

For IdP tokens:

reason

Meaning and fix

wrong_audience

The token was minted for someone else. Mint it with aud = forumulate:<orgId> (or the configured audience).

unknown_issuer

No reader sign-in provider on this space has that issuer. Check the provider config, or the iss your IdP puts in its tokens.

expired

The token has expired. Mint a fresh one per redirect; never cache a reader token.

bad_signature

The signature did not verify against the issuer's keys. Is the token from the configured instance?

jwks_unreachable

We could not fetch the issuer's keys. Retry. If it persists, check the jwks_uri the discovery document names.

lifetime_too_long

The token lives longer than this provider allows. Shorten its lifetime.

not_yet_valid

nbf or iat is in the future. Check the clock of the server that minted it.

wrong_token_use

The token is not the kind this provider accepts (token_use).

bad_algorithm

Only asymmetric algorithms (RS*, PS*, ES*, EdDSA) are accepted for IdP tokens.

unknown_key

No key in the issuer's JWKS matches the token's kid, even after a refetch.

missing_claim

A required claim (sub, exp) is missing.

malformed

That is not a JWT.

discovery_failed

The discovery document could not be read, or names a different issuer.

not_configured

This space has no reader sign-in provider that accepts tokens.

For external_jwt tokens, the reasons are those in the JWT guide: malformed, bad_signature, bad_algorithm, unknown_key, jwks_unavailable, bad_issuer, bad_audience, expired, not_yet_valid, iat_too_old, iat_in_future, missing_claim, invalid_claim, missing_email and not_configured. external_jwt keeps its older words (bad_audience, jwks_unavailable) for compatibility. @forumulate/reader (and map_error / reader.MapError in the Python and Go ports) maps both spellings to the same advice.

New reasons may be added. Existing ones are never renamed.

Test it with curl

For a shared-secret (external_jwt) space, mint a token with Node and redeem it:

ORG=07f432f4-…; SPACE=…; SECRET='a-long-random-string'
REDEEM="https://yourcompany.com/docs/_api/public/orgs/$ORG/spaces/$SPACE/reader-auth/jwt"

JWT=$(SECRET="$SECRET" node -e '
const c=require("node:crypto"),n=Math.floor(Date.now()/1000),e=v=>Buffer.from(JSON.stringify(v)).toString("base64url");
const h=e({alg:"HS256",typ:"JWT"}),b=e({sub:"test-1",email:"test@example.com",aud:"forumulate-docs",iss:"https://app.yourcompany.com",iat:n,exp:n+120});
process.stdout.write(h+"."+b+"."+c.createHmac("sha256",Buffer.from(process.env.SECRET,"utf8")).update(h+"."+b).digest("base64url"))')

curl -H "Forumulate-Version: 2027-03-01" -si -X POST "$REDEEM" -H 'content-type: application/json' \
  -d "{\"jwt\":\"$JWT\",\"returnTo\":\"https://yourcompany.com/docs\"}"

Expected:

HTTP/2 200
set-cookie: cflx_vis_…; Path=/; HttpOnly; Secure; SameSite=Lax
set-cookie: forumulate_session=…; Path=/; HttpOnly; Secure; SameSite=Lax

{"ok":true,"returnTo":"https://yourcompany.com/docs","pending":false}

For an IdP provider, paste a token from your IdP in place of $JWT. A token minted for another audience answers:

HTTP/2 401
{"error":{"code":"auth.token_invalid","reason":"wrong_audience","message":"That token was not accepted (wrong_audience): …"},"requestId":"…"}

Moving readers to your IdP

If your readers signed in through external_jwt (keyed on your own user id) and you now switch to your IdP (keyed on (iss, sub)), relink them first. Otherwise every reader comes back as a stranger, without their posts or standing.

POST /orgs/{orgId}/members/relink
{
  "from": { "provider": "external_jwt" },
  "to":   { "provider": "atlas", "issuer": "https://accounts.yourcompany.com" },
  "map":  [ { "externalId": "user_8f21c0", "sub": "user_2abcDEF" } ],
  "onConflict": "stop",
  "idempotencyKey": "relink-2026-10-24"
}
  • It is a dry run by default. It reports what would happen and changes nothing. Add ?apply=true to do it.

  • map takes at most 50,000 rows. For more, send mapUrl: an https URL of NDJSON, one {"externalId": "…", "sub": "…"} per line.

  • When the source is oidc and the target is atlas_byo, and the sub already is the external id, the call is a provider switch and needs no map.

  • Conflicts. A target (iss, sub) that another member already holds is reported in conflicts[] and left alone with onConflict: "stop", the default. With "merge", the source member is folded into the holder. Two external ids that map to the same sub are merged the same way. In a merge, topics and posts are re-owned, standing is summed and principals are joined.

  • The call is idempotent. Re-running it changes nothing that is already linked (unchanged), and the same idempotencyKey with the same body answers the original job.

It answers 202 with a job:

{
  "id": "…",
  "kind": "member.relink",
  "state": "queued",          // queued | running | completed | failed | canceled
  "progress": { "done": 0, "total": 1 },
  "result": null,             // when completed:
  // { "dryRun": false, "matched": 1, "unmatched": [], "conflicts": [], "merged": [],
  //   "unchanged": 0, "totals": { "before": {members, posts, standing}, "after": {…} } }
  "error": null,
  "createdAt": "…",
  "finishedAt": null,
  "links": {
    "self": "/api/orgs/{orgId}/jobs/{jobId}",
    "status": "/api/orgs/{orgId}/members/relink/{jobId}",
    "result": null            // once an applied relink completed: "/api/orgs/{orgId}/members"
  }
}

It is a job like every other background operation: poll GET /orgs/{orgId}/members/relink/{jobId} or GET /orgs/{orgId}/jobs/{jobId} (the same object) until state is completed, run forumulate jobs wait <jobId>, or subscribe to the job.completed webhook, which carries this object once when the relink finishes. Compare totals.before with totals.after: members, posts and standing should match, less any merges you asked for. Each applied run is audited as member.relinked, with the API token that ran it.

Run the dry run, read the result, then apply. Switch the space's provider after the relink has completed.

Change a space's sign-in method

Moving a space from one provider to another (the common case: external_jwt to Atlas) is four decisions. These are the rules the code applies, in the order they matter.

1. Who the reader is changes with the provider. A reader is keyed on (issuer, subject). With external_jwt the subject is your user id (the token's sub); with Atlas it is the Atlas sub. They are different keys, so a reader who signs in through the new provider without a relink is a stranger: a second member, without their posts or standing. Relink first (see Moving readers to your IdP): the map is externalId (your id) to sub (Atlas's).

2. Email matching needs a verified address. An address is never what keys a reader, and it reaches an existing account only when the provider vouches for it:

  • an IdP provider (oidc, atlas, ...) links a new (iss, sub) to an existing account with the same email only when reader.idp_email_collision is link_verified and the token says email_verified: true; the default (separate) never links;

  • external_jwt links a first sign-in to the account that owns the address when the token's address is verified, and refuses it 409 (email_unverified) when it is not. An absent email_verified is verified before API version 2027-03-01 and unverified from it. Send the claim.

3. Removing a provider signs its readers out. When a save leaves a method configured nowhere in the organization (not in the default registry, not in any space or site override), the readers who came in through it lose their session and reader cookie shortly after the answer. A method still configured at another level keeps its readers; changing a provider's config (rotating a secret, a new label) signs nobody out. Their members are kept: nothing is deleted, and a reader who signs in again through what the organization offers now is the same member if you relinked. A reader with no recorded identity (provisionIdentity: false on an external_jwt that names no issuer) has only a reader cookie we cannot look up, which lives for up to 12 hours.

4. Order of operations. One call at the end, not three in a row:

  1. Relink, dry run, read the result, apply, wait for the job.

  2. Make one change that adds the new provider, removes the old and clears the page that pointed at the old one:

    PATCH /orgs/{orgId}/reader-auth
    {
      "providers": [
        { "method": "atlas", "config": { "instanceId": "ins_…", "audience": "forumulate:{orgId}" } },
        { "method": "external_jwt", "remove": true }
      ],
      "signInUrl": null
    }
  3. Read the answer's warnings[] and the provider's resolved.signInUrl. Reload a gated page signed out: the reader surface shows Continue with Atlas.

In a manifest the same move is the declared form, where apply replaces:

readerAuth:
  providers:
    - { method: atlas, config: { instanceId: ins_…, audience: "forumulate:ORG" } }

and the plan prints - remove provider external_jwt and ~ signInUrl -> null.

The pieces

  • remove: true drops the provider the entry matches by method (add order when the registry holds more than one of that method). A method that is not there is a no-op, not an error. The rest of the registry is untouched.

  • signInUrl: null clears the page. Sent through PUT it is cleared by leaving it out; in the declared manifest form, by leaving it out too. A signInUrl is your page: while one is set, readers get one link to it instead of the provider buttons. An Atlas provider is the exception: its resolved.signInUrl is its own hosted sign-in ({issuer}/hosted/sign-in, unless the provider sets config.signInUrl), and the reader surface shows Continue with Atlas beside it.

  • warnings[], on every write: sign_in_url_overrides_providers (a signInUrl is set while a provider draws a button), signin_url_unreachable (the page answered 404 or nothing when you saved), and sign_in_url_return_to_unconfirmed (the page does not appear to read returnTo; put {returnTo} in the URL, or read the parameter). They never refuse the save.

  • provisionIdentity (default true) is whether a signed-in reader becomes a member. Removing the provider leaves those members, their identities and their content as they are; only their sessions end. With false there was never a member, only a cookie.

In the API

Every operation below is also reachable from the other surfaces without a dedicated command. forumulate api <Operation> calls it from the CLI, the JavaScript SDK has a typed method for each one (the last column names it; the Python and Go SDKs are generated from the same spec), and an agent reaches the organization-scoped ones through the MCP agent server — its api_* family tools, or call_api. Where a CLI command or a hand-made MCP tool exists too, the last column names it. Operation names link to the API reference.

Jobs

Operation

Request

What it does

Shortcuts

Jobs_list

GET /orgs/{orgId}/jobs

List recent background jobs

forumulate jobs list<br>client.jobs.list()

Jobs_get

GET /orgs/{orgId}/jobs/{jobId}

Get a background job

forumulate jobs get<br>forumulate jobs wait<br>client.jobs.get()

Logout Receivers

Operation

Request

What it does

Shortcuts

LogoutReceivers_list

GET /orgs/{orgId}/reader-auth/logout-receivers

Where each reader IdP sends logouts and lifecycle events

client.logoutReceivers.list()

LogoutReceivers_register

POST /orgs/{orgId}/reader-auth/logout-receivers

Register our back-channel logout receiver with your IdPs

client.logoutReceivers.register()

Member

Operation

Request

What it does

Shortcuts

Member_list

GET /orgs/{orgId}/members

List members in an organization

forumulate members list<br>client.members.list()

Member_invite

POST /orgs/{orgId}/members

Invite a member

forumulate invites create<br>client.members.invite()

Member_removeByExternal

DELETE /orgs/{orgId}/members

Remove every member your identity provider knows by an external ID

forumulate members remove-external<br>client.members.removeByExternal()

Member_invites

GET /orgs/{orgId}/members/invites

List invites in an organization

forumulate invites list<br>client.members.invites()

Member_revokeInvite

DELETE /orgs/{orgId}/members/invites/{inviteId}

Revoke the invite (members)

forumulate invites revoke<br>client.members.revokeInvite()

Member_bulkInvite

POST /orgs/{orgId}/members/bulk

Invite members in bulk

forumulate invites bulk<br>client.members.bulkInvite()

Member_listInviteLinks

GET /orgs/{orgId}/members/invite-links

List invite links in an organization

forumulate invites links list<br>client.members.listInviteLinks()

Member_createInviteLink

POST /orgs/{orgId}/members/invite-links

Create an invite link in an organization

forumulate invites links create<br>client.members.createInviteLink()

Member_revokeInviteLink

DELETE /orgs/{orgId}/members/invite-links/{linkId}

Revoke the invite link

forumulate invites links revoke<br>client.members.revokeInviteLink()

Member_update

PATCH /orgs/{orgId}/members/{userId}

Update the member

forumulate members update<br>client.members.update()

Member_remove

DELETE /orgs/{orgId}/members/{userId}

Delete the member

forumulate members remove<br>client.members.remove()

Member_revokeSessions

POST /orgs/{orgId}/members/{userId}/sessions/revoke

Sign a member out of every session

forumulate members sign-out<br>client.members.revokeSessions()

Member_reassignContent

POST /orgs/{orgId}/members/{userId}/reassign-content

Reassign the content

forumulate members reassign<br>client.members.reassignContent()

Operation

Request

What it does

Shortcuts

MemberRelink_relink

POST /orgs/{orgId}/members/relink

Relink members to a new reader identity key

client.members.relink.relink()

MemberRelink_get

GET /orgs/{orgId}/members/relink/{jobId}

A member relink job

client.members.relink.get()

Reader Idp Public

Operation

Request

What it does

Shortcuts

ReaderIdpPublic_backchannelLogout

POST /public/orgs/{orgId}/oidc/backchannel-logout

Receive an OIDC back-channel logout for readers

client.readers.publicIdp.backchannelLogout()

ReaderIdpPublic_atlasBackchannelLogout

POST /public/orgs/{orgId}/atlas/backchannel-logout

Receive an OIDC back-channel logout for readers (Atlas alias)

client.readers.publicIdp.atlasBackchannelLogout()

ReaderIdpPublic_lifecycle

POST /public/orgs/{orgId}/identity/events/{preset}

Receive reader lifecycle events from an identity provider

client.readers.publicIdp.lifecycle()

ReaderIdpPublic_oktaVerification

GET /public/orgs/{orgId}/identity/events/okta

Get the okta in an organization

client.readers.publicIdp.oktaVerification()

Reader Sessions

Operation

Request

What it does

Shortcuts

ReaderSessions_revoke

POST /orgs/{orgId}/reader-sessions/revoke

End every session of a reader, on every host

client.readers.sessions.revoke()

The Forumulate teamUpdated