Sign readers in from your own app with a JWT
Your application already knows who its users are. external_jwt lets you hand that identity straight to a Forumulate space: you mint a short-lived JWT, send the reader to one URL with it, and they arrive signed in. No redirect dance, no second password, no OIDC server to run.
Use this when the reader is already authenticated in your product. If you want them to sign in at the docs, use one of the providers in Reader sign-in methods instead.
1. Configure the space
PUT /orgs/{orgId}/spaces/{spaceId}/reader-auth
{
"providers": [
{
"method": "external_jwt",
"order": 0,
"config": {
"sharedSecret": "a-long-random-string-you-generate",
"clientId": "forumulate-docs",
"issuer": "https://app.yourcompany.com"
}
}
]
}PUT /orgs/{orgId}/reader-auth does the same for every space in the organization; the space-level call overrides it.
Key | Required | What it does |
|---|---|---|
| one of these two | HS256 symmetric signing. Simplest. |
| one of these two | An https JWKS endpoint of yours. Use this for RS256/ES256 and for key rotation. Takes precedence if both are set. |
| no | When set, the token's |
| no | When set, the token's |
Setting neither sharedSecret nor jwksUrl is a 422 at save time: external_jwt needs a jwksUrl or sharedSecret.
config holds flat scalars only — strings, numbers, booleans. The body is strict at both levels: a misshapen provider list is a 422 rather than a 200 that quietly stores nothing.
The secret is write-only. GET …/reader-auth — and the response to the PUT itself — returns sharedSecretSet: true in place of the value, and every provider carries secretSet: true|false (a secret is stored for it).
A secret is never lost by omission. On a PUT, a secret that is absent, empty, or a redaction mask (********) keeps the stored value, and the sharedSecretSet / secretSet markers are ignored — so you can send a GET back verbatim. To change one field without resending anything, use PATCH (a deep merge — see Reader sign-in: the API):
PATCH /orgs/{orgId}/spaces/{spaceId}/reader-auth
{ "providers": [{ "method": "external_jwt", "config": { "issuer": "https://app.yourcompany.com" } }] }The only way to remove a stored secret is to say so: PATCH with the key set to null.
GET /orgs/{orgId}/spaces/{spaceId}/reader-auth/setup?method=external_jwt returns requiredConfig: [] and oneOfConfig: ["jwksUrl", "sharedSecret"], plus startUrl — the redeem address on your space's primary address — and readerEndpoints, the redeem and sign-out URLs at every address the space is served from (see Redeem it). The org-level GET /orgs/{orgId}/reader-auth/setup?method=external_jwt templates startUrl on the host your spaces are read on (https://yourcompany.com{mount}/_api/… when they share your domain) and lists each space's concrete startUrl and readerEndpoints in spaces[].
2. The claims
Claim | Type | Required | Notes |
|---|---|---|---|
| string | yes | Your stable user id. This is the identity key — a reader who changes their email at your end comes back as the same member. |
| string | yes | Rejected with 422 if absent. |
| number | yes | Epoch seconds. Must be within the freshness window. |
| number | yes | Epoch seconds. A token minted without one would live forever, so its absence is fatal. |
| string or array | only if | Must equal |
| string | only if | Must equal |
| string | no | Falls back to |
| boolean | no | Absent means verified. Send |
| number | no | Honoured if present, with the same 30-second skew allowance. |
3. The algorithm
On the shared-secret path, HS256 is pinned. A token presenting any other alg is refused before any claim is read.
That pin is not optional hygiene. Without it, a caller could present an RS256 token signed with a key of their own choosing and we would use the shared secret as a public key — the classic JWT algorithm-confusion bug, which turns a shared secret into "anyone may sign in as anyone".
On the JWKS path the key material fetched from your endpoint constrains the algorithm, and the keys are cached and rotated for you. jwksUrl must be https.
The signing key on the shared-secret path is the UTF-8 bytes of the sharedSecret string exactly as you stored it. It is not base64-decoded first — a mistake that produces a token that verifies in your test harness and nowhere else.
4. The freshness window
300 seconds, checked against iat — not exp, not nbf — with a 30-second clock-skew allowance in both directions on every time claim:
Check | Refused when |
|
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
The skew allowance is for clocks that are seconds apart, not minutes: keep your servers on NTP. A token minted one or two seconds "in the future" by a clock slightly ahead of ours is accepted.
A token that never expires because somebody set exp a year out is still a replay risk, which is why freshness is checked separately from expiry. Mint a fresh token for each redeem; do not cache one.
5. Redeem it
On the reader's own host
The redeem sets the reader's cookies, and those cookies are host-only: they belong to the host that answered the redeem. Redeem on forumulate.com and the cookies land on forumulate.com — where your docs at docs.yourcompany.com (or yourcompany.com/docs) never see them, and the reader is still signed out.
So redeem on the address your readers read, through the same-origin API every Forumulate space is served with under its mount:
https://<your-host><mount>/_api/public/orgs/{orgId}/spaces/{spaceId}/reader-auth/jwtYou do not have to build that by hand. GET /orgs/{orgId}/spaces/{spaceId}/reader-auth answers readerEndpoints — one entry per address the space is served from, your custom domain first:
"readerEndpoints": [
{
"url": "https://yourcompany.com/docs",
"kind": "domain", // or "site" (a space inside a site) / "platform"
"primary": true,
"apiBase": "https://yourcompany.com/docs/_api",
"jwtRedeemUrl": "https://yourcompany.com/docs/_api/public/orgs/{orgId}/spaces/{spaceId}/reader-auth/jwt",
"signOutUrl": "https://yourcompany.com/docs/_api/public/reader/sign-out"
},
{
"url": "https://forumulate.com/p/your-org/docs",
"kind": "platform",
"primary": false,
"apiBase": "https://forumulate.com/api",
"jwtRedeemUrl": "https://forumulate.com/api/public/orgs/{orgId}/spaces/{spaceId}/reader-auth/jwt",
"signOutUrl": "https://forumulate.com/api/public/reader/sign-out"
}
]The same object is on GET …/reader-auth/setup?method=external_jwt, and GET /orgs/{orgId}/spaces/{spaceId}/domains/{domainId}/setup answers it for the one domain it describes. Use the entry whose url your returnTo is under.
Two shapes, same effect.
Redirect (what you normally want)
Send the reader's browser to:
GET https://yourcompany.com/docs/_api/public/orgs/{orgId}/spaces/{spaceId}/reader-auth/jwt
?jwt=<compact token>
&returnTo=https://yourcompany.com/docs/guides/introAnswers 302 to returnTo, with the reader's cookies set. When the space has required custom fields the reader has not filled, cflx_fields=1 is added to that address, and the reader page asks for them (then removes the flag).
Missing jwt answers 400 with a one-line HTML body.
POST (for a fetch from your own front end)
POST https://yourcompany.com/docs/_api/public/orgs/{orgId}/spaces/{spaceId}/reader-auth/jwt
{
"jwt": "<compact token>",
"returnTo": "https://docs.yourcompany.com/guides/intro"
}{ "ok": true, "returnTo": "…", "pending": false, "needsFields": [] }A fetch must be made from a page on the reader's host with credentials: 'include' — a server-to-server POST sets cookies on nobody's browser.
jwt is 20–8192 characters; returnTo is up to 2048. Both endpoints are rate limited to 30 requests a minute per caller.
returnTo is re-validated on our side against the space's verified custom domains plus its platform URL. An address outside that set is not an error — it silently falls back to the space's primary address, which is the custom domain when one exists. So an open-redirect attempt lands the reader on your docs, and a typo in returnTo looks like "it ignored my parameter".
A worked example
Stored config for the space:
{
"method": "external_jwt",
"order": 0,
"config": {
"sharedSecret": "s3cr3t-at-least-32-bytes-please",
"clientId": "forumulate-docs",
"issuer": "https://app.yourcompany.com",
},
}Header:
{ "alg": "HS256", "typ": "JWT" }Claims, with now = 1767225600:
{
"sub": "user_8f21c0",
"email": "dana@example.com",
"email_verified": true,
"name": "Dana Okafor",
"aud": "forumulate-docs",
"iss": "https://app.yourcompany.com",
"iat": 1767225600,
"exp": 1767225900
}Signature: HMAC-SHA256 over base64url(header) + "." + base64url(claims), keyed with the UTF-8 bytes of s3cr3t-at-least-32-bytes-please.
In Node:
import { SignJWT } from 'jose';
const secret = new TextEncoder().encode(process.env.FORUMULATE_READER_SECRET);
export async function docsLinkFor(user, path) {
const jwt = await new SignJWT({
sub: user.id,
email: user.email,
email_verified: true,
name: user.displayName,
})
.setProtectedHeader({ alg: 'HS256' })
.setIssuer('https://app.yourcompany.com')
.setAudience('forumulate-docs')
.setIssuedAt()
.setExpirationTime('2m')
.sign(secret);
// readerEndpoints[0].jwtRedeemUrl from GET …/spaces/{spaceId}/reader-auth
const url = new URL(JWT_REDEEM_URL);
url.searchParams.set('jwt', jwt);
url.searchParams.set('returnTo', `https://yourcompany.com/docs${path}`);
return url.toString();
}Put that behind a route of your own — /docs on your app — so the link in your navigation always mints a fresh token.
In Python:
import time, jwt # PyJWT
from urllib.parse import urlencode
def docs_link_for(user, path):
now = int(time.time())
token = jwt.encode(
{
"sub": user.id,
"email": user.email,
"email_verified": True,
"name": user.display_name,
"iss": "https://app.yourcompany.com",
"aud": "forumulate-docs",
"iat": now,
"exp": now + 120,
},
SHARED_SECRET, # the string, as stored
algorithm="HS256",
)
# JWT_REDEEM_URL = readerEndpoints[0].jwtRedeemUrl from GET …/reader-auth
return JWT_REDEEM_URL + "?" + urlencode(
{"jwt": token, "returnTo": f"https://yourcompany.com/docs{path}"}
)What a reader gets
A visitor pass — cflx_vis_<spaceId>, or cflx_vis_org_<orgId> when the space shares one identity across the organization — HttpOnly, SameSite=Lax, Path=/, host-only, valid for 12 hours. With provisionIdentity (the default) also a member session, forumulate_session (30 days), and the readable fm_signed_in=1 flag. Every one of them is set for the host that answered the redeem; a redeem on your domain never sets a cookie for any other domain. After the pass expires they come back through your link, which mints a new token — so your app stays the only thing that decides who may read. Every reader cookie, and how sign-out clears them, is listed in Reader sign-in: the API.
Signing out
POST <signOutUrl> — the readerEndpoints[].signOutUrl on the reader's host — revokes every session the sign-in created and clears every reader cookie there. Call it from a page on that host (fetch(url, { method: 'POST', credentials: 'include' })) when your user signs out of your app. The other direction — the reader signs out of the docs and you want them signed out of your app too — is signOutUrl on the reader-auth config; see Reader sign-in: the API.
Errors
Every refusal names its cause twice: in English in error.message, and as one stable word in error.reason — branch on that, not on the message.
{
"error": {
"code": "auth.token_invalid",
"reason": "iat_in_future",
"message": "That token was not accepted (iat_in_future): \"iat\" claim timestamp check failed (it should be in the past)."
},
"requestId": "…"
}Status |
|
| Cause |
|---|---|---|---|
401 |
|
| Not a compact JWT (three dot-separated segments), or unparseable. |
401 |
|
| Signed with a different secret or key. |
401 |
|
| An |
401 |
|
| No key in your JWKS matches the token's |
401 |
|
| Your |
401 |
|
|
|
401 |
|
|
|
401 |
|
|
|
401 |
|
|
|
401 |
|
|
|
401 |
|
|
|
401 |
|
|
|
401 |
|
| A time claim that is not a number, or another malformed claim. |
404 |
| — | The space does not offer |
422 |
|
| No |
422 |
|
| The space has neither a |
New reasons may be added; existing ones are never renamed. None of it is a secret: these are properties of a token the caller minted themselves, and withholding the reason turns a five-minute integration into an afternoon.
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 clients are generated from the same spec), and an agent reaches the organization-scoped ones through the MCP call_api tool. Where a CLI command or MCP tool exists too, the last column names it. Operation names link to the API reference.
Reader Auth
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| The organization's reader sign-in registry |
| |
| Replace the organization's reader sign-in registry |
| |
| Change part of the organization's reader sign-in registry |
| |
| Values to register at your IdP for ORG-WIDE reader sign-in |
| |
| Values to register at your IdP for one space |
| |
| One space's reader sign-in override |
| |
| Replace one space's reader sign-in override |
| |
| Change part of one space's reader sign-in override |
| |
| Remove one space's reader sign-in override |
|