Reader sign-in with your app
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:
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.
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: |
Keys |
|
| Must equal the resolved issuer. A trailing slash is ignored, so Auth0's |
| Must contain the audience. The default is |
| Required, and no further out than the provider's maximum lifetime (Atlas: 120 seconds). |
Clock skew | 30 seconds either way on |
| Checked when the preset or config names one (Atlas: |
| 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.
Your own sign-in links come back too
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 |
|---|---|
|
|
|
|
|
|
| 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 |
| Max token lifetime |
|---|---|---|---|---|---|
|
|
|
|
| 120 s |
|
|
|
| — | 3600 s |
|
|
|
| — | 600 s |
|
|
|
| — | 3600 s |
|
|
| the app client id |
| 3600 s |
|
|
|
| — | 3600 s |
|
|
| the application id | — | 5400 s |
|
|
| the client id | — | 3600 s |
|
|
| the project id | — | 3600 s |
|
|
|
| — | 3600 s |
|
|
|
| — | 600 s |
|
|
|
| — | 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 |
|---|---|
| The issuer tokens must carry. Discovery is read at |
| Where the discovery document is read, when it is not under the issuer. |
| The key set, used directly instead of discovery's |
| The audience the token must carry. |
| The claim the audience is read from, when it is not |
| The |
| The longest lifetime ( |
| The claim each fact is read from. Dotted paths work: |
| 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 3The 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-authanswersrecommendations: [{code: "reader_auth.use_atlas", message, docs, patch, manifest, relink}].patchis thePATCH /orgs/{orgId}/reader-authbody that switches;relinkis the member relink to run first, so nobody comes back as a stranger.A manifest plan (
PUT /orgs/{orgId}/manifest,version: 2) answers the samerecommendationswhen 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_…"}(orfrontendApifor a custom domain). It expands into an organization-levelreader_authentry, so it cannot be combined with one.forumulate doctor --site <site> --org <org>reports it as thereader-auth-atlascheck.
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_jwttokens are minted by your own server (its ownissuer, an HS256secret, or a JWKS that is not onatlasauth.netor 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'sissuer/jwksUrlat your Atlas instance or link the instance (a policy source link withconsumer: "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 |
| The ID token (audience = application id) |
| The ID token from Google Identity Services | |
WorkOS |
| An AuthKit access token with |
Any OIDC IdP |
| Any JWT it signs with |
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/jwtRedeem 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 |
|
|
Fetch |
|
|
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 examplemerchant:<uuid>) andemailare stored as attributes. The members list shows them, andGET /orgs/{orgId}/members?externalId=finds a member by them.principalis 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 keyreader.idp_email_collisiontolink_verifiedto link to that account instead, and only when the token saysemail_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 |
|
|---|---|
A token you signed ( |
|
A redirect sign-in ( | The method: |
Your IdP's own token, redeemed at | The IdP preset: |
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
algis refused.The key is the UTF-8 bytes of the secret string as stored. It is not base64-decoded.
sub,email,iatandexpare required.audmust equalclientIdandissmust equalissuerwhen those are configured.iatmay 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-pythonimport 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-goimport 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[].urlvalues.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.signOutUrlisreaderEndpoints[].signOutUrlon the reader's host, andorgId/spaceIdtake the uuid or the slug. That request revokes the session, clears every reader cookie on that host, and answers302toreturnTo.returnTomay be an address of the space, or anhttpsURL on the origin of the space's reader-authsignInUrlorsignOutUrl(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' }), wheresignOutUrlisreaderEndpoints[].signOutUrl.From your server, with no browser involved:
POST /orgs/{orgId}/reader-sessions/revokewith{ "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-logoutis the same receiver.) The logout token is verified against the issuer's JWKS. Itsaudmust be the provider's audience or client id,eventsmust containhttp://schemas.openid.net/event/backchannel-logout, it must not carry anonce, and it must name asidor asub. We then end the matching reader sessions on the server. Asidends that one session and asubends 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 |
|---|---|---|
| Atlas, Clerk, |
|
| WorkOS |
|
| Auth0 log streams, Okta event hooks |
|
| anything else |
|
Each vendor's events are mapped to five types:
Event | What we do | Key (space → org → default) |
|---|---|---|
| Refresh the reader's email, name and | — |
| Suspend the reader in every space of the organization now, and end their sessions. |
|
| Lift that suspension. | — |
| Open a privacy-executor closure, honouring |
|
| 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).
|
| |
|---|---|---|
You give us |
| issuer, client id (and secret), scopes |
We store per reader | the | the same |
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: | you send them to |
Deletion policy |
| the same key |
Cookies, for your consent banner
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 |
|---|---|---|---|
|
| 30 days | The reader's member session. |
|
| session | Tells the page to render the signed-in header. |
|
| 12 hours | Reader pass for the whole organization (org identity scope). |
|
| 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:
| Meaning and fix |
|---|---|
| The token was minted for someone else. Mint it with |
| No reader sign-in provider on this space has that issuer. Check the provider config, or the |
| The token has expired. Mint a fresh one per redirect; never cache a reader token. |
| The signature did not verify against the issuer's keys. Is the token from the configured instance? |
| We could not fetch the issuer's keys. Retry. If it persists, check the |
| The token lives longer than this provider allows. Shorten its lifetime. |
|
|
| The token is not the kind this provider accepts ( |
| Only asymmetric algorithms ( |
| No key in the issuer's JWKS matches the token's |
| A required claim ( |
| That is not a JWT. |
| The discovery document could not be read, or names a different issuer. |
| 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=trueto do it.maptakes at most 50,000 rows. For more, sendmapUrl: an https URL of NDJSON, one{"externalId": "…", "sub": "…"}per line.When the source is
oidcand the target isatlas_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 inconflicts[]and left alone withonConflict: "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 sameidempotencyKeywith 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 whenreader.idp_email_collisionislink_verifiedand the token saysemail_verified: true; the default (separate) never links;external_jwtlinks a first sign-in to the account that owns the address when the token's address is verified, and refuses it409(email_unverified) when it is not. An absentemail_verifiedis verified before API version2027-03-01and 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:
Relink, dry run, read the result, apply, wait for the job.
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 }Read the answer's
warnings[]and the provider'sresolved.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: truedrops the provider the entry matches bymethod(addorderwhen 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: nullclears the page. Sent throughPUTit is cleared by leaving it out; in the declared manifest form, by leaving it out too. AsignInUrlis your page: while one is set, readers get one link to it instead of the provider buttons. An Atlas provider is the exception: itsresolved.signInUrlis its own hosted sign-in ({issuer}/hosted/sign-in, unless the provider setsconfig.signInUrl), and the reader surface shows Continue with Atlas beside it.warnings[], on every write:sign_in_url_overrides_providers(asignInUrlis set while a provider draws a button),signin_url_unreachable(the page answered 404 or nothing when you saved), andsign_in_url_return_to_unconfirmed(the page does not appear to readreturnTo; put{returnTo}in the URL, or read the parameter). They never refuse the save.provisionIdentity(defaulttrue) 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. Withfalsethere 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 |
|---|---|---|---|
| List recent background jobs |
| |
| Get a background job |
|
Logout Receivers
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| Where each reader IdP sends logouts and lifecycle events |
| |
| Register our back-channel logout receiver with your IdPs |
|
Member
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| List members in an organization |
| |
| Invite a member |
| |
| Remove every member your identity provider knows by an external ID |
| |
| List invites in an organization |
| |
| Revoke the invite (members) |
| |
| Invite members in bulk |
| |
| List invite links in an organization |
| |
| Create an invite link in an organization |
| |
| Revoke the invite link |
| |
| Update the member |
| |
| Delete the member |
| |
| Sign a member out of every session |
| |
| Reassign the content |
|
Member Relink
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| Relink members to a new reader identity key |
| |
| A member relink job |
|
Reader Idp Public
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| Receive an OIDC back-channel logout for readers |
| |
| Receive an OIDC back-channel logout for readers (Atlas alias) |
| |
| Receive reader lifecycle events from an identity provider |
| |
| Get the okta in an organization |
|
Reader Sessions
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| End every session of a reader, on every host |
|