Skip to content

This document was written by AI and has been manually reviewed.

Configuration

Site configuration is stored in the site_config D1 table and editable at runtime through Admin → Settings. No redeployment is needed to change any of these values.

Sensitive keys (captcha secrets — both the global and the China Turnstile one — social client secrets, SMTP/IMAP passwords, the GitHub README PAT, Discord bot token) are encrypted at rest with AES-GCM via the SECRETS_KEY Cloudflare Secrets Store binding. The admin panel transparently decrypts on read; values are never exposed via the config API.

General

KeyTypeDefaultDescription
site_namestring"Prism"Displayed in the browser title and emails
site_descriptionstring"Federated identity platform"Shown on the login page
site_icon_urlstring?nullURL to a favicon / logo
allow_registrationbooleantrueAllow new users to self-register
invite_onlybooleanfalseRequire an invite token to register, even when allow_registration = true
require_email_verificationbooleanfalseBlock login until email is verified
accent_colorstring"#0078d4"Primary brand color (hex). Drives FluentUI theme
custom_cssstring""Injected as a <style> block on every page
disable_user_create_teambooleanfalseHide the "New team" button — only admins can create teams
disable_user_create_appbooleanfalseHide the "New application" button — only admins can create OAuth apps
allow_alt_email_loginbooleantrueLet users sign in with any verified secondary email, not just primary
initializedbooleanfalseSet to true after first-run setup. Do not change manually

Two operator-authored documents — a Privacy Policy and Terms of Service — can be published from Admin → Settings → Legal. Each is written in Markdown (rendered and sanitized the same way as profile READMEs) and served at its own public page, reachable without signing in:

DocumentPageAPI endpoint
Privacy Policy/privacyGET /api/legal/privacy
Terms of Service/termsGET /api/legal/terms

Unlike the settings above, these are not stored in site_config — they live in a dedicated legal_documents D1 table (slug, content, updated_at, updated_by). A policy is a large document, and site_config is read in full on essentially every request; keeping the documents out of it avoids loading them on the hot path. Each is capped at 256 KiB.

A link to each published page appears in the footer of every page (signed in or out). Clearing a document (saving it empty) hides both the page and its footer link. The public GET /api/site payload does not include the full text — it only exposes the has_privacy_policy / has_terms_of_service booleans so the footer knows which links to render; the content is fetched on demand when a reader opens the page, and the endpoint returns the last-updated timestamp for a "Last updated" line.

Sessions & tokens

KeyTypeDefaultDescription
session_ttl_daysnumber30Session JWT lifetime. Per-user override via users.access_token_ttl_minutes / refresh_token_ttl_days (admin-only)
access_token_ttl_minutesnumber60OAuth access token lifetime (default; per-user override available)
refresh_token_ttl_daysnumber30OAuth refresh token lifetime (default; per-user override available)

Bot protection (captcha)

Exactly one provider can be active at a time. The captcha is challenged on register, login, password change, email-verification resend, and any flow the admin enables.

KeyTypeDefaultDescription
captcha_providerstring"none"none | turnstile | hcaptcha | recaptcha | pow
captcha_site_keystring""Public site key. For Turnstile, the global (region: "world") widget
captcha_secret_keystring""Server-side secret for that key (encrypted at rest)
turnstile_endpoint_modestring"global"Turnstile-only. Which host serves the widget (see below)
turnstile_china_site_keystring""Turnstile-only. Site key of a region: "china" widget (see below)
turnstile_china_secret_keystring""Turnstile-only. Secret for that key (encrypted at rest)
pow_difficultynumber20Leading zero bits required for proof-of-work (higher = harder)

Turnstile: two hosts, two widgets

Cloudflare serves Turnstile from a global host (challenges.cloudflare.com) and a Mainland-China host (challenges.cloudflare-cn.com). The two are not interchangeable, which shapes the whole setting:

  • Every Turnstile widget carries a regionworld or china — chosen when the widget is created.
  • A widget only works on the host matching its region. A region: "world" site key sent to the China host gets HTTP 400, which the widget reports as Error: 400020 on a form that can then never be submitted.
  • region is immutable: the API rejects a change with you cannot change region. An existing global key can never be promoted.
  • Creating a region: "china" widget needs an entitlement tied to a China Network contract — without it the API answers not entitled to create widgets with this region. Cloudflare's China Network docs put it plainly: "Turnstile is not available within Mainland China."

So using the China host means switching the site key as well as the host. That is what turnstile_china_site_key / turnstile_china_secret_key are for: a second widget, created with region: "china", sitting beside the global pair in captcha_site_key / captcha_secret_key.

If turnstile_china_site_key is empty, every mode below behaves as global. Nothing breaks if you pick a China-leaning mode without holding the entitlement — visitors are simply served the global widget.

turnstile_endpoint_mode

Picks which of the two pairs a given visitor gets:

  • global — always the global host and key.
  • china — always the China host and key.
  • client_language — the browser picks China when its language is Chinese (zh*).
  • server_region — the server picks China when the request's edge geolocation is CN.
  • client_region — the browser picks China when its timezone is a Mainland-China zone.

Because the decision selects a key as well as a host, both site keys are sent to the browser for the client-side modes; site keys are public, so this discloses nothing. The token comes back tagged with the widget that minted it (captcha_variant), and the server verifies it against that widget's secret.

Validation. A configured China key is trusted only after it answers: Prism requests a challenge for it from the China host, caches the verdict in KV_CACHE for 6 h on success and 1 h on failure, and falls back to global whenever it fails. This catches a key typed into the wrong field, a widget that was actually created region: "world", and a lapsed entitlement. The probe runs in the background, so a cold cache serves the global host rather than waiting on it.

Why the fallback is server-side. The Turnstile bundle reads its challenge origin from its own <script> tag exactly once at load, and the base-url parameter that would override it is disabled in the production bundle. A widget that has already loaded from the wrong host cannot be redirected without reloading the page, so the choice has to be right before the browser commits.

Proof-of-work requires no third-party service. The Rust→WASM solver in pow/ runs ~10× faster than the JS fallback. Difficulty 20 takes ~0.1–2 s depending on device. Values above 24 may time out on low-end mobile devices. PoW is single-use and replay-protected via the pow_used table.

Two-factor / step-up

KeyTypeDefaultDescription
sudo_mode_ttl_minutesnumber5After a successful step-up, subsequent challenges from the same (user, session, app) skip the TOTP/passkey prompt for this many minutes. 0 disables sudo mode entirely
require_captcha_for_2fabooleanfalseSite-wide: every step-up confirmation must solve the active captcha. Apps can also opt in per challenge. No-op when captcha_provider = none

Public profiles

User and team public profiles are off until the user (or team owner) explicitly opts in. Site defaults apply only to fields the user has not customized — they never silently flip a private profile to public.

User profile defaults

KeyTypeDefaultDescription
enable_public_profilesbooleantrueMaster kill switch. false ⇒ both /u/:username and /t/:id always 404
default_profile_show_display_namebooleantrue
default_profile_show_avatarbooleantrue
default_profile_show_emailbooleanfalseSensitive — opt-in even when the rest of the profile is public
default_profile_show_joined_atbooleantrue
default_profile_show_gpg_keysbooleantrue
default_profile_show_authorized_appsbooleanfalseReveals the user's connected services — opt-in
default_profile_show_owned_appsbooleantrue
default_profile_show_domainsbooleantrue
default_profile_show_joined_teamsbooleanfalseAlso gates appearing in any team's public member list
default_profile_show_readmebooleantrueREADME is itself opt-in (empty = hidden); this only matters if the user has written one
profile_readme_max_bytesnumber65536Hard cap on README markdown source size

Team profile defaults

KeyTypeDefaultDescription
default_team_profile_show_descriptionbooleantrue
default_team_profile_show_avatarbooleantrue
default_team_profile_show_ownerbooleanfalseOpt-in: would otherwise reveal the owner's username
default_team_profile_show_member_countbooleantrue
default_team_profile_show_appsbooleantrue
default_team_profile_show_domainsbooleantrue
default_team_profile_show_membersbooleanfalseThe full member list. Each member's own profile_show_joined_teams still applies
default_team_profile_show_sub_teamsbooleantrueSub-team listing. Each child must also be public to actually appear

There is no site default for the master profile_is_public flag — privacy-first. The team owner (or admin) must always set it explicitly.

Sub-teams (nested teams)

Master switch and inheritance toggles for the sub-team feature. See that page for the full semantics; the keys themselves:

KeyTypeDefaultDescription
enable_sub_teamsbooleantrueMaster switch. When false every sub-team endpoint returns 403.
max_team_depthinteger5Hard cap on nesting depth (root = 0). Admin API validates 1–20.
inherit_team_membershipbooleantrueCascade member roles to descendants (effective role = max(direct, inherited)).
inherit_team_domainsbooleantrueSurface ancestor-owned domains on sub-team listings + use them for auto-verify.

Member groups

Site-wide fallback for the per-team capability set behind member groups. There is no master switch — the feature is opt-in per team and off by default, so nothing is emitted until a team owner turns it on.

KeyTypeDefaultDescription
default_team_role_permissionsobject{}Fallback capability grants for the admin role, e.g. {"admin":{"groups:manage":true}}

Resolution order is per-group override → team role_permissions → this key → built-in defaults (groups:manage off, groups:assign on). Each level only contributes the keys it explicitly sets, so an empty object here simply lets every team fall through to the built-ins.

Master switch and bounds for invite-link registration. Off by default — turning it on is what makes a team owner able to mint accounts at all, and even then each team still needs its own grant from Admin → Teams.

KeyTypeDefaultDescription
enable_team_invite_registrationbooleanfalseMaster switch. While false every related endpoint 403s.
team_invite_registration_max_uses_capinteger1000Ceiling on the max_uses a team may set on a registration-capable invite.
team_invite_registration_rate_per_hourinteger200Registrations per hour per invite. Per-IP limiting can't bound a widely shared link.
restricted_user_capabilitiesobject{}Features granted to restricted accounts, e.g. {"app:create": true}. Empty = all denied.
restricted_pending_ttl_hoursinteger72How long an unfinished registration survives before the reaper deletes it.
restricted_dissolve_grace_hoursinteger168Grace period between a dissolution deactivating accounts and deleting them.

Capability keys: team:create, app:create, domain:create, pat:create, profile:public, gpg:manage, self:convert. Anything absent falls back to the built-in defaults, which deny everything. Account security is never gated.

Team join requirements (site floor)

Site-wide minimums every team is forced to require, regardless of the team-level flag. Owners can opt their team in further but cannot opt out below the floor.

KeyTypeDefaultDescription
default_team_require_2fabooleanfalseFloor: every team requires at least one TOTP authenticator or passkey
default_team_require_verified_emailbooleanfalseFloor: every team requires a verified primary email

WARNING

Turning these on retroactively forces every existing member to satisfy the factor — anyone who hasn't enrolled is locked out of team operations until they do. Roll them out behind a member-side notice.

GitHub README sync

Users can opt to sync their public profile README from a GitHub user repo. Cache respects ETag and serves stale-on-error.

KeyTypeDefaultDescription
github_readme_tokenstring""Site-global GitHub PAT used as the last-resort token for README fetches. Empty = unauthenticated 60 req/h per IP. Encrypted at rest
github_readme_cache_ttl_secondsnumber3600Serve cached README for this long before issuing a conditional GET
github_readme_token_failuresnumber0Auto-managed: site PAT 401 counter. Auto-clears the token at 3 failures

GPG login

KeyTypeDefaultDescription
gpg_challenge_prefixstring""Extra lines inserted between the site header and the random challenge in the clearsign payload. Use this to add a human-readable marker so users can verify the challenge they're signing comes from your site

Third-party notifications

KeyTypeDefaultDescription
tg_notify_source_slugstring""Slug of an enabled Telegram OAuth source whose bot is used to deliver Telegram notifications. Leave empty to disable Telegram delivery. The source's bot token doubles as the bot used to message users
discord_notify_source_slugstring""Slug of an enabled Discord OAuth source used to identify linked Discord users for notification DMs. Leave empty to disable Discord delivery
discord_bot_tokenstring""Discord bot token used to open DM channels and send notification messages. This is separate from the Discord OAuth source client secret

Social login

Each OAuth source (GitHub, Google, Microsoft, Discord, Telegram, X, Cloudflare, Generic OIDC, Generic OAuth 2) is now a row in the oauth_sources table — managed in Admin → OAuth Sources, not here. The legacy keys below remain readable for backwards compatibility but new deployments should use OAuth Sources directly.

Key (legacy)Description
github_client_idGitHub OAuth App Client ID
github_client_secretGitHub OAuth App Client Secret
google_client_idGoogle Cloud OAuth 2.0 Client ID
google_client_secretGoogle Cloud OAuth 2.0 Client Secret
microsoft_client_idAzure AD Application (client) ID
microsoft_client_secretAzure AD Client Secret
discord_client_idDiscord Application ID
discord_client_secretDiscord Client Secret

All *_client_secret values are encrypted at rest. Callback URL format for sources is:

https://your-domain/api/connections/<slug>/callback

Email — Sending

KeyTypeDefaultDescription
email_providerstring"none"none | resend | mailchannels | smtp
email_api_keystring""API key for Resend or Mailchannels (encrypted)
email_fromstring"noreply@example.com"From address for outgoing emails
smtp_hoststring""SMTP server hostname (when provider is smtp)
smtp_portnumber587SMTP server port
smtp_securebooleanfalseUse SSL/TLS (true) or STARTTLS (false)
smtp_userstring""SMTP username
smtp_passwordstring""SMTP password (encrypted)

Email — Receiving

KeyTypeDefaultDescription
email_verify_methodsstring"both"link (system sends email) | send (user sends email to verify) | both
email_receive_providerstring"cloudflare"cloudflare (Email Workers) | imap (poll via IMAP) | none
email_receive_hoststring""Domain for verify-<code>@<host> emails (Cloudflare only). Blank = derive from APP_URL
imap_hoststring""IMAP server hostname (when receive provider is imap)
imap_portnumber993IMAP server port
imap_securebooleantrueUse implicit TLS (true, port 993) or STARTTLS (false, port 143)
imap_userstring""IMAP username — also shown to users as the destination address (with code as subject)
imap_passwordstring""IMAP password (encrypted)
social_verify_ttl_daysnumber0When non-zero, an email verified through a social provider stays trusted for this many days before re-verification is requested. 0 disables expiry

Domain verification

Domains can be verified via DNS TXT, an HTML meta tag, or a .well-known file — whichever the user picks at add time. Verified domains are re-checked on the configured cron interval.

KeyTypeDefaultDescription
domain_reverify_daysnumber30Days between automatic re-verification checks for domains

Diagnostics & rate limiting

KeyTypeDefaultDescription
login_error_retention_daysnumber30How long failed-login rows in the login_errors table are kept before the cron purges them
ipv6_rate_limit_prefixnumber64Prefix length used to bucket IPv6 addresses in the rate limiter (so a /64 doesn't get unlimited retries)

Wrangler bindings & variables

These are configured in wrangler.jsonc and not editable from the admin panel.

Variables

VariableRequiredDescription
APP_URLYesFull origin of the deployment, e.g. https://auth.example.com
LOCKDOWN_USERSNoComma / semicolon / whitespace separated list of usernames that cannot be deleted by anyone (including admins). Leave empty to disable.
LOCKDOWN_TEAMSNoComma / semicolon / whitespace separated list of team names that cannot be deleted by anyone (including admins). Leave empty to disable.
ENABLE_RESETNoSet to "true" to enable the Admin → Settings → Danger Zone → Site reset button. When unset or anything else the button is hidden (destructive).
D1_CONSOLENoAvailability of Admin → Database. Off unless set. Unset, "off" (also "false" / "0" / "no" / "disabled" / "none") and anything unrecognised all remove the surface: the endpoints 404 and the tab disappears. "read-only" (also "readonly" / "read") allows browsing and SELECT but refuses every write, including one that sets allow_write. "full" (also "on" / "true" / "1") is unrestricted — except the audit log, which is append-only from the console at every setting.
KV_CONSOLENoAvailability of the key–value browser in the same panel. Same values as D1_CONSOLE. Unset follows D1_CONSOLE — the two are windows onto the same instance's storage, so a setting for one is a statement about both unless this one says otherwise. With neither set, both are off.
NO_RESET_COOLDOWNNoSet to "true" to skip the 30-minute cooldown between requesting a reset and being allowed to confirm it. 2FA is still required even when this is set.

Bindings

BindingKindRequiredNotes
DBD1 databaseYesAll persistent state
KV_SESSIONSKV namespaceYesSession JWT secret, RSA keypair (for ID token signing), per-session metadata
KV_CACHEKV namespaceYesRate-limit counters, IMAP poll cursors, image-proxy cache
ASSETSWorkers AssetsYesBuilt SPA. html_handling: "none" so SSR can render / itself
SECRETS_KEYSecrets Store secretStrongly recommended32-byte base64url AES-GCM master key. When bound, all sensitive D1 fields encrypt at rest

SECRETS_KEY setup

Generate a 32-byte master key:

bash
node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"

Create the Secrets Store, store the key under name prism-secrets-key, and add the secrets_store_secrets binding shown in wrangler.jsonc.

After redeploying, run the migration once from Admin → Settings → Danger Zone → "Migrate secrets to Secrets Store" to encrypt existing OAuth/source/SMTP/IMAP/captcha credentials in D1. Bearer-style secrets (PATs, OAuth codes, OAuth tokens, invite tokens, email-verify codes, 2FA codes, individual backup codes) are migrated to a keyed HMAC-SHA256 hash in a companion endpoint ("Migrate D1 secrets") so they remain look-up-able by value but are not recoverable from the database.

If SECRETS_KEY is not bound, encryption/hashing is a no-op — the legacy plaintext path keeps working until you opt in.

Cron triggers

jsonc
"triggers": { "crons": ["0 */6 * * *"] }

Every 6 hours the worker:

  • re-verifies domains whose next_reverify_at has passed,
  • polls the IMAP mailbox (when email_receive_provider = imap),
  • purges the app_event_queue and expired pow_used rows,
  • sweeps orphaned image_proxy_mappings (mappings whose source row no longer exists).

User / team deletion lockdown

When you have users or teams that must never be deleted — shared service accounts, bot identities, or critical organisational teams — set LOCKDOWN_USERS and LOCKDOWN_TEAMS in wrangler.jsonc. Locked accounts return a 403 error when any admin (or team owner) attempts to delete them through the API.

The variables accept a comma, semicolon, or whitespace separated list of names. Leading and trailing whitespace on each name is stripped.

Released under the GPL-3.0 License.