Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Forseti

Forseti is a self-service UI and OAuth2 login / consent / logout bridge for Ory Kratos and Ory Hydra. It gives your identity stack the pages it’s missing: login, registration, account recovery, MFA, consent, and admin tooling, all server-rendered in Rust.

These docs are split by what you’re here to do:

  • User guide — for people using an account: signing in, registering, recovery, MFA, and account settings.
  • Operator guide — deployment topology, Kratos/Hydra config, secrets, backups.
  • Reverse proxy — proxy topology, cookies, CSRF, CORS.
  • Integration guide — consuming Forseti as an OIDC provider.
  • Commercial features — organizations, enterprise SAML SSO, and observability.

The source lives on GitHub. Forseti is AGPL-3.0 with a commercial option.

User Guide

For people using an account on a site that runs Forseti. You don’t need to install or configure anything; this covers the self-service pages you’ll see when you sign in, create an account, or manage your profile. If you run the service, see the operator guide; if you’re building an app against it, see the integration guide.

Signing in

Enter your email and password on the login page. If the site has other sign-in methods enabled (a social or enterprise login), you’ll see them as buttons on the same page. After signing in you’re returned to the app you came from.

Some apps route you into a specific organization when you sign in. If you’re not already a member, you may see a one-time “Join <Org>?” page asking you to confirm before you continue; you can also choose to continue without joining. You won’t be asked again once you’ve joined.

Creating an account

The registration page asks for your email and a password, plus whatever profile fields the operator has configured. Some sites verify your email address before the account is fully active; if so, you’ll get a message with a link or code to confirm.

Recovering access

If you’ve forgotten your password, use the “forgot password” link on the login page. You’ll receive a recovery link or code by email, then get to set a new password. Recovery is rate-limited, so if a message doesn’t arrive, wait a moment before trying again.

Two-factor authentication (2FA)

From your account settings you can turn on a second factor for extra protection. The usual option is an authenticator app (TOTP): scan the QR code, enter the six-digit code to confirm, and save the recovery codes somewhere safe. After that, sign-in asks for a code from your app in addition to your password.

Managing your account

The account settings pages let you update your profile and email, change your password, manage your 2FA methods, and review or end active sessions.

When an app asks to use your account, you’ll see a consent screen listing what it wants access to. You can approve or decline. Sites that support it let you review and revoke previously connected apps from your account settings.

Signing out

Use the sign-out option to end your session. Depending on how the site is set up, this may also sign you out of the connected apps you reached through it.

Operator Guide

A reference for deploying forseti as the self-service UI and OAuth2 login/consent bridge for an Ory Kratos + Ory Hydra installation at, for example, accounts.example.com.

This guide assumes self-hosting the full stack: forseti, Kratos, Hydra, and Postgres. It does not assume prior familiarity with Kratos’s surface area.

For app-developer documentation on how downstream applications consume Forseti as an OIDC Provider, see integration-guide.md. For project status and milestones, see ../README.md and ../ROADMAP.md.

What this is

forseti is a Rust + Axum self-service UI for Ory Kratos and an OAuth2 login/consent/logout bridge for Ory Hydra. It renders Kratos’s self-service flows (login, registration, recovery, verification, settings) and implements the three handlers Hydra delegates to the IdP: /oauth/login, /oauth/consent, /oauth/logout. Branding is config-driven; there are no hardcoded organization names. Licensing: AGPL-3.0-or-later for the OSS core, with src/commercial/ under the proprietary source-available Forseti Commercial License 1.0 — see the License section of the README.

Deployment topology

The recommended shape is path-prefixed on a single host: Forseti at the root, Hydra under /hydra/*, Kratos under /kratos/*. Everything is same-origin, cookies are host-only, no CORS to configure, only :443 exposed. Hydra’s production guide explicitly endorses this layout. The subdomain shape (accounts.example.com / hydra.example.com / kratos.example.com) is also supported when there’s a reason to split — different rate-limit tiers, independent WAF rules, splitting Hydra to its own cluster. See operator-guide-proxy.md for the comparison and haproxy configs.

                          Internet
                             |
                             v
                  +-----------------------+
                  |     Reverse proxy     |  TLS termination
                  |    (haproxy / nginx)  |  X-Forwarded-* headers
                  +-----------------------+      strip /hydra and /kratos prefixes
                             |
                             v
                  accounts.example.com:443
                     /        |         \
                    /         |          \
                   v          v           v
                  /        /hydra/*    /kratos/*
                  |           |            |
                  v           v            v
            +-----------+  +--------------+  +----------------+
            | forseti|  | Hydra public |  |  Kratos public |
            |   :3000   |  |    :4444     |  |     :4433      |
            +-----------+  +--------------+  +----------------+
                  |              |                  |
                  |  admin calls (server-to-server, on private network)
                  |              |                  |
                  v              v                  v
                  +- (Hydra admin :4445) (Kratos admin :4434) -+
                                       |
                                       v
                                  +--------+
                                  |Postgres|
                                  | :5432  |
                                  +--------+
  • The reverse proxy is the only ingress. TLS terminates here. X-Forwarded-Proto: https and X-Forwarded-Host are mandatory — without them Hydra/Kratos emit http:// URLs and CSRF cookies without Secure.
  • Forseti listens on :3000 and serves at the root.
  • Hydra’s public API is served under /hydra/* with the prefix stripped before the upstream sees it. Hydra’s issuer and public URLs are set to https://accounts.example.com/hydra so discovery emits the right jwks_uri, token_endpoint, etc.
  • Kratos’s public API is served under /kratos/* with the prefix stripped. serve.public.base_url is set to https://accounts.example.com/kratos. The browser hits Kratos directly for some operations — CSRF token resolution, whoami cookie handling, /.well-known/ory/webauthn.js.
  • Hydra and Kratos do not honour subpath mounting natively (hydra#352, kratos#1152) — the proxy strips the prefix, the upstreams serve at root, and the published URLs carry the prefix because the issuer/base_url config tells them to.
  • Kratos’s admin API (:4434) and Hydra’s admin API (:4445) are bound to the internal network. Forseti calls them server-side. Never expose admin APIs through the public proxy.
  • Postgres holds the identity store (Kratos), the OAuth2 state and JWKS (Hydra), and Forseti’s own data. Internal-only.
  • All cookies — Forseti session/CSRF, ory_hydra_session, ory_kratos_session, plus the per-flow CSRF cookies each service emits — are host-only on accounts.example.com, SameSite=Lax, Secure, HttpOnly. Don’t set cookies.domain on Kratos or Hydra in this shape; host-only is tighter and there’s no cross-subdomain traffic to enable.

Prerequisites

  • Postgres (>= 13). One database per service: kratos, hydra, optionally forseti. The playground’s init-db.sh shows the bootstrap pattern.
  • Mail provider. Mailcrab (used in infra/docker-compose.yml) is a development sink. In production, use a real provider. Two pieces of the stack send mail independently: Kratos (verification, recovery, MFA-enrol mails) speaks SMTP via courier.smtp.connection_uri in kratos.yml, and Forseti (org-invite + claim-email mails) sends via [email] in config.toml, which supports Lettermint, Postmark, SendGrid, or an SMTP relay. Both can point at the same SMTP relay.
  • DNS records pointing accounts.example.com, kratos.example.com, and hydra.example.com (or a single hostname with path-based routing) at the reverse proxy.
  • TLS certificates. Let’s Encrypt via Caddy or certbot, or a managed cert solution.
  • Container runtime if running Forseti as a container, or a Linux host with a writable working directory if running the static binary.

Configuration

Forseti loads configuration from config.toml (or the path in $FORSETI_CONFIG_PATH) and overlays environment variables prefixed with FORSETI_. Section separator is a double underscore: FORSETI_KRATOS__PUBLIC_URL sets kratos.public_url.

The authoritative schema is src/config.rs. The example file is config.example.toml. Every key:

[kratos]

KeyTypeDefaultDescription
public_urlstringBrowser-facing Kratos URL. Forseti redirects users here to initialize flows and proxies cookies.
admin_urlstringServer-only Kratos admin URL. Used for identity reads, session enumeration, session revocation.

[hydra]

KeyTypeDefaultDescription
public_urlstringPublic Hydra issuer URL (token endpoint, JWKS, OAuth2 endpoints).
admin_urlstringServer-only Hydra admin URL. Used to fetch and accept login/consent/logout challenges.

[self]

KeyTypeDefaultDescription
urlstringForseti’s own externally reachable URL. Used to build return_to round-trips.

[security]

KeyTypeDefaultDescription
cookie_secretstringephemeral per-bootSeeds the HMAC keys for every Forseti-signed cookie. Long random secret.
frame_ancestorsstring"'self'"CSP frame-ancestors on every public_app response. "'none'" blocks framing entirely.
x_frame_optionsbooltrueAlso emit X-Frame-Options: SAMEORIGIN for older browsers.

cookie_secret is the root key behind the HMAC for Forseti’s signed cookies (one-shot flash, active_org switcher, forseti_app_referrer handoff, CSRF double-submit). Each cookie derives its own key from this secret plus a per-use domain-separation salt (see src/flash.rs, src/orgs/cookie.rs, src/handoff/cookie.rs).

Generate one with openssl rand -hex 32 (a hex string is decoded to bytes; anything that isn’t valid hex is taken as raw UTF-8 bytes). The decoded key must be at least 32 bytes or Forseti hard-fails at boot. Override via FORSETI_SECURITY__COOKIE_SECRET.

When unset, Forseti generates a 32-byte ephemeral key per process and logs a warning. That means flash, active-org, and app-referrer cookies don’t survive a restart, and separate instances can’t validate each other’s cookies — so set cookie_secret in production and on any multi-instance deployment. None of these are Forseti’s session cookie (that’s Kratos), and none are catastrophic on their own (the flash banner is short-lived, the org cookie’s selection is re-validated at use, the handoff cookie’s referrer_uri is re-checked against the Hydra client), but a stable secret avoids the restart churn.

CSRF protection uses a double-submit token (src/csrf.rs) keyed off the same secret; there’s nothing extra to configure for it.

[brand]

KeyTypeDefaultDescription
namestring"Forseti"Brand name shown in the header, page titles, and email templates.
support_emailstringnoneSupport address rendered in footer / error pages.
logo_urlstringnoneOptional logo URL. When omitted, the brand name is rendered as text.
consent_introstring(generic sentence)Intro paragraph rendered on /oauth/consent above the scope list.
theme_presetstringnoneGlobal theme preset applied to every page: default, midnight, or cyberpunk. Each derives its own dark-mode variant automatically. A per-org preset overrides this within that org’s scope.
brand_primarystringnoneGlobal primary brand colour (#rrggbb). Overrides the preset’s primary.
brand_on_primarystringnoneForeground colour used on top of brand_primary (#rrggbb); set it to keep text legible on a custom primary.
brand_secondarystringnoneSecondary / accent brand colour (#rrggbb).
operator_trust_anchorstringnoneOperator identity shown on pre-auth cards (login, consent, device verify). The strongest anti-phishing lever against a tenant impersonating the operator brand — never set this from tenant-controlled input.

[[apps]]

Zero or more entries. Each renders a card on the dashboard’s “Your apps” section. Omit the section to hide the dashboard block.

KeyTypeDefaultDescription
namestringCard title.
descriptionstring""One-line description under the title.
urlstringLink target.

[database]

Forseti-owned database. Separate from the Kratos/Hydra Postgres — schema isolation, independent backups, no risk of colliding with Ory’s migrations. Both sqlite and Postgres are first-class backends.

KeyTypeDefaultDescription
urlstring"sqlite://./forseti.db"sqlite://path/to/file.db (or a bare path) for single-binary self-hosters; postgres://user:pass@host/db for HA. URL scheme picks the backend.
skip_migrationsboolfalseWhen true, the boot-time migration run is skipped. Use this when schema changes are gated through a deploy pipeline instead of the running binary.

Defaulting to sqlite-next-to-the-binary is deliberate: clone, run, get a working Forseti with persistent state. Operators who want Postgres set [database] explicitly.

Multi-instance sqlite footgun. sqlite + multiple Forseti instances corrupts the database. Forseti can’t see other instances, only deployment shape — so at boot it logs a warn! and surfaces a banner on /admin/status when the backend is sqlite and self.url is https:// with a non-loopback / non-RFC1918 host. Switch to Postgres for any HA setup.

Per-process state in multi-instance deployments. Postgres makes the database safe to share, but a few components are process-local, so running several instances changes their behavior:

  • Webhook delivery: every instance runs its own outbox worker. Rows are claimed with a short lease before sending, so each row is delivered by exactly one worker; extra instances add polling, not duplicate deliveries.
  • Rate limits: the per-IP and global buckets are in-memory, per instance. Behind a load balancer the effective limit is roughly the configured value times the instance count; size the configured limits with that in mind.
  • Logo cache: each instance caches served org logos independently. After a logo is replaced or removed, the instance that handled the change drops its cached copy immediately; other instances may serve the previous version until the entry is evicted under cache pressure or the process restarts.
  • Domain-challenge email cooldown: the one-hour cooldown between ownership-challenge emails to a domain is tracked per instance, so N instances can send up to N challenge emails per domain per hour.

Migrations run on startup by default (FORSETI_DATABASE__SKIP_MIGRATIONS=1 to opt out). The two backends carry parallel SQL files under migrations/{sqlite,postgres}/.

The playground compose file ships a dedicated forseti-postgres sidecar on 127.0.0.1:5450 (separate from the Kratos/Hydra postgres, per the design’s schema-isolation goal). Smoke-boot the Postgres path with:

FORSETI_DATABASE__URL="postgres://forseti:secret@localhost:5450/forseti" cargo run

[internal]

KeyTypeDefaultDescription
bindstring"127.0.0.1:8081"Bind address for the internal listener (the audit webhook receiver and the POSIX resolver). Never expose this on a public interface — see Internal listener.

The [posix] table (uid/gid bases, default shell, home prefix, free-tier seat cap) is documented in Linux authentication → [posix].

[email]

Forseti-owned outbound mail (org invites + claim-email). Kratos’s courier handles its own self-service mail separately. Optional — omit the section (or set enabled = false) and the send sites log + skip so dev still works with the token / code accessible via the DB. Backed by polymail: provider selects the transport and the remaining fields are that provider’s credentials, flattened in directly under [email].

Sender identity and switch:

KeyTypeDefaultDescription
enabledbooltrueMaster switch. When false (or the section is absent), Forseti logs the would-be recipient and returns without sending.
from_addressstringFrom address. Falls back to noreply@<self.url host> when unset. Required when enabled = true.
from_namestringOptional display name paired with from_address.
providerstringTransport: lettermint, postmark, sendgrid, or smtp.

Provider fields (only those matching the chosen provider):

ProviderFields
letterminttoken (source from FORSETI_EMAIL__TOKEN in prod)
postmarktoken (source from FORSETI_EMAIL__TOKEN in prod)
sendgridapi_key (note: not token; source from FORSETI_EMAIL__API_KEY)
smtphost; port (optional, defaults per tls: 465 implicit, 587 start_tls); tls one of none/start_tls/implicit (default implicit); user; pass (source from FORSETI_EMAIL__PASS)

Environment variables override TOML field by field (Figment, FORSETI_ prefix, __ for nesting), so leave secrets blank in the file and inject them at runtime. polymail refuses to send SMTP credentials over tls = "none", and Forseti fails startup on an enabled provider with a blank token / missing from_address.

[webhook]

Outbound webhook signing (today: account-deletion fan-out, signed as RFC 8417 Security Event Tokens). Receivers verify via the JWKS at /.well-known/webhook-jwks.json.

KeyTypeDefaultDescription
signing_key_pathstring"data/webhook-signing-key.pem"On-disk PEM (PKCS#8) Ed25519 key. When missing on boot, Forseti auto-generates a fresh Ed25519 key, writes it 0600, and logs a warning — back it up. Forseti uses Ed25519 (RFC 8037) per NIST SP 800-131A Rev 3 §5.6.4; a file at this path that isn’t a valid Ed25519 PKCS#8 PEM is a hard startup error — remove or replace it.

Rotating the webhook signing key

Rotation is a stop-replace-restart procedure today. There’s no key-rollover window — Forseti signs every SET with whatever key it loaded at boot, and kid is derived deterministically from the public key, so a key swap means a new kid.

  1. Generate a new PEM (Ed25519, PKCS#8) out-of-band, or just delete the existing file and let Forseti regenerate on boot.
  2. Stop Forseti.
  3. Replace data/webhook-signing-key.pem (mode 0600, owned by the service user).
  4. Start Forseti. It logs the new kid and serves the new public key at /.well-known/webhook-jwks.json.

In-flight deliveries queued before the swap are already signed with the old kid and stay in the outbox. They’ll deliver successfully against receivers that re-fetch JWKS on kid miss (the integration guide recommends this — see Idempotency and retries). Receivers that cache JWKS aggressively and don’t refetch on miss will reject them; if you have such integrators, drain webhook_outbox (wait for CONFIRMED count to reach 0) before rotating.

Keep at least one backup of the previous key for forensic verification of historical SETs. Don’t reuse kids.

[oauth.scope_descriptions]

Map of scope name to human-readable description, surfaced on /oauth/consent. Unknown scopes fall back to the raw scope name. Example:

[oauth.scope_descriptions]
openid         = "Sign you in with your account"
email          = "Access your verified email address"
profile        = "View your basic profile (name)"
# `offline_access` is the OIDC Core 1.0 §11 standard name. `offline` is a
# Hydra-ism kept as a back-compat alias — both map to the same "issue a
# refresh token" semantics. Prefer `offline_access` for new clients.
offline_access = "Stay signed in by issuing refresh tokens"
offline        = "Stay signed in by issuing refresh tokens"

[oauth] — DCR knobs

Per-IP / per-IAT rate limiting on POST /oauth2/register, plus the reserved-name denylist and the RFC 8707 resource bridge. Defaults are set in code; override per-deployment when needed. See Dynamic Client Registration (RFC 7591) for the full picture.

KeyTypeDefaultDescription
allowed_resource_audiencesstring[][]Resource identifiers Forseti may bind into an access token’s aud when a client requests them with RFC 8707 resource=. Unlisted resources are ignored and logged. Empty disables the bridge. See below.
dcr_require_iatboolfalseRequire a valid initial access token on POST /oauth2/register. Left off, anonymous dynamic client registration stays open; turned on, an anonymous request is rejected with 401 invalid_token.
dcr_ip_rate_per_minuteu3210Per-IP rate limit on POST /oauth2/register — max requests per minute. In-memory, per-process. 0 disables this bucket.
dcr_ip_rate_per_houru32100Per-IP rate limit — max requests per hour. Enforced in parallel with the per-minute bucket. 0 disables.
dcr_global_rate_per_minuteu3240Global (all-callers-share-one-bucket) rate limit, requests per minute. Bounds total traffic even when a spoofed X-Forwarded-For defeats the per-IP bucket. 0 disables.
dcr_global_rate_per_houru32400Global rate limit, requests per hour, in parallel with the per-minute global bucket. 0 disables.
dcr_iat_daily_limitu3250Per-IAT cap on successful registrations over a rolling 24h window opened by first use. 0 disables.
dcr_reserved_namesstring[](code-baked set)DCR client_name denylist. Case-insensitive substring match. When the key is absent from config.toml, the defaults in crate::oauth::register::RESERVED_NAMES_DEFAULT are used; setting the key replaces the list entirely.

RFC 8707 resource → access-token audience

OAuth clients that target a specific resource server — every MCP client, for instance — name it with RFC 8707 resource=<uri> on the authorize request. Hydra/fosite ignores that parameter entirely: it derives the requested audience only from Hydra’s non-standard audience= form parameter. A client that does the standard thing therefore receives a token with aud: [], and its resource server rejects it forever.

Listing a resource in allowed_resource_audiences makes Forseti bridge the gap:

[oauth]
allowed_resource_audiences = ["https://stackpit.gofranz.com/mcp"]
  • At consent, any resource= value on the authorize URL that matches the list is merged into the granted access-token audience. Values that don’t match are dropped with a tracing::warn! — the allow-list is what stops Forseti becoming an open audience-minting service for anyone who can reach /oauth2/auth.
  • At registration, the same resources are unioned into the audience of every client created through POST /oauth2/register. fosite re-validates the granted audience against the client record on the refresh grant (but not on the initial code exchange), so without the pre-registration a client gets one working access token and then invalid_request on every refresh.
  • Comparison strips the trailing slash and any fragment, so https://host/mcp and https://host/mcp/ are one resource. The no-trailing-slash form is what gets granted and registered.

The list is a ceiling, not a grant: an audience only reaches a token when the user consents to a request that actually asked for that resource. Clients using Hydra’s audience= parameter are unaffected.

Whether the per-IP limiter trusts forwarded-for headers is a single deployment-wide knob: [proxy] trust_forwarded_for (see below). The same flag drives the audit middleware (audited client IP) and the handoff + claim-email limiters — the underlying question (“is there a trusted reverse proxy?”) doesn’t change per-endpoint.

Every rate-limit knob across [oauth], [claim_email], and [handoff] is clamped at config-load time to a sanity ceiling — 1_000 per minute, 10_000 per hour, 100_000 per day. A clamped value emits a tracing::warn! at boot so an operator typo (per_minute = 1_000_000) is loud rather than silent. 0 is preserved as the documented “disable this bucket” sentinel.

[auth] configuration

Per-IP + global rate limiting on GET /registration. These knobs apply to every Kratos-flow registration, not just external-org self-serve joins — /registration carries no per-org dimension in the URL (the target org lives inside the opaque Kratos flow), so there’s no cheap way to key a bucket per org.

KeyTypeDefaultDescription
registration_ip_rate_per_minuteu3230Per-IP rate limit on GET /registration, requests per minute. 0 disables the bucket.
registration_ip_rate_per_houru32300Per-IP rate limit on GET /registration, requests per hour, in parallel with the per-minute bucket. 0 disables.
registration_global_rate_per_minuteu32120Global (all-callers-share-one-bucket) rate limit, requests per minute. Bounds total traffic even when a spoofed X-Forwarded-For defeats the per-IP bucket. 0 disables.
registration_global_rate_per_houru321200Global rate limit, requests per hour, in parallel with the per-minute global bucket. 0 disables.

These are clamped at load time under the same ceilings as [oauth]/[orgs]/[claim_email]/[handoff]. See External access mode for what this limiter does and does not cover.

[proxy] — reverse-proxy trust

KeyTypeDefaultDescription
trust_forwarded_forboolfalseHonour X-Forwarded-For / X-Real-IP / Forwarded when deriving the audited client IP and keying per-IP rate limiters. Set true ONLY when the upstream reverse proxy strips client-sent forwarded-for headers before re-adding its own — otherwise a direct caller can forge the header and spoof their IP. See proxy guide.

Safety precondition: the listener must be unreachable except through the trusted proxy. There is no trusted-proxy allowlist — when trust_forwarded_for = true, Forseti takes the first X-Forwarded-For hop from whoever opened the connection, with no check that the peer is your proxy. Two things must both hold before you enable it: (1) the proxy strips client-sent X-Forwarded-* before re-adding its own, and (2) Forseti’s listener accepts connections only from the proxy — bind it to loopback or a private interface, or firewall the port. If either fails, anyone who can reach the listener directly sets their own X-Forwarded-For and thereby controls both the client IP recorded in the audit log (making audit rows attributable to an IP of their choosing) and the key for every per-IP rate-limit bucket (a fresh header value per request means an unlimited number of fresh buckets, so the per-IP limits stop limiting anything). The global rate-limit buckets are the only backstop in that case. With trust_forwarded_for = false (the default) Forseti keys on the TCP peer address, which cannot be forged.

Environment overrides

Every key is overridable via env var:

FORSETI_KRATOS__PUBLIC_URL=https://kratos.example.com
FORSETI_KRATOS__ADMIN_URL=http://kratos.internal:4434
FORSETI_AUDIT__WEBHOOK_TOKEN="$(openssl rand -hex 32)"
FORSETI_BRAND__NAME="Example Accounts"

Recommended pattern: keep non-secret structural config in config.toml, load secrets from env (typically injected by your secrets manager or orchestrator).

Appearance

Users choose between System, Light, and Dark from a control in the page footer (on both signed-in pages and the login/registration screens). The default is System, which follows the browser/OS setting.

The choice is stored in a per-browser cookie (forseti_theme), not on the account — it doesn’t follow a user across devices or browsers. The server reads the cookie and renders the theme directly, so there’s no flash on load; operating the control itself requires JavaScript.

Branded deployments: the dark palette flips the brand colour to a light tone by default. A single brand colour that passes contrast checks on a light background usually won’t on the dark one, so set a dark-mode brand override under the html.dark scope if you ship custom brand colours.

Language

The UI ships with nine locales: English (en, default), German (de), French (fr), Spanish (es), Italian (it), Portuguese (pt), Russian (ru), Thai (th), and Arabic (ar). Arabic renders right-to-left. Kratos’s own error and prompt messages are translated too, so a login failure reads in the visitor’s language rather than falling back to Kratos English.

A visitor picks a language from the footer switcher, which appends ?lang=<code> and persists the choice in a per-browser cookie (forseti_locale, one year, HttpOnly, SameSite=Lax). With no cookie set, Forseti negotiates against the browser’s Accept-Language header and falls back to English. The set is compile-time (translations live in locales/, embedded into the binary); there’s no config knob to add or restrict locales at runtime.

Forseti serves three public, themed legal pages — /privacy, /terms, and /imprint — linked from the footer on every page. These are instance-level (the operator is the GDPR data controller), not per-org. Out of the box each serves a short English stub embedded in the binary, meant to be replaced.

To override them, point [legal].dir at a directory and drop Markdown files named {doc}.{locale}.md into it, where doc is privacy, terms, or imprint and locale is one of the supported subtags (en, de, fr, …). Resolution per request is {doc}.{locale}.md{doc}.en.md → the shipped default, so privacy.de.md serves German visitors while privacy.en.md covers everyone else. You don’t have to provide every doc or every language; anything missing falls back down that chain.

[legal]
dir = "/etc/forseti/legal"

Notes:

  • A set-but-missing or unreadable dir is a startup error (fail fast, not a silent fallback). Omit the section entirely to keep the built-in defaults.
  • The Markdown is rendered with raw HTML stripped (<script>, embedded <div>, etc. are dropped, not emitted), so style the pages with Markdown, not inline HTML.
  • Files are read on each request (off the async runtime), so editing a file takes effect without a restart.

Admin surface

Forseti exposes an operator-facing admin surface under /admin/* for managing the Ory stack from the same UI users sign in through. There is no separate admin binary or out-of-band tooling.

[admin]

KeyTypeDefaultDescription
allowed_emailsstring[][]Lowercased and matched case-insensitively against the session’s traits.email. Empty list (or omitted section) closes /admin/* to everyone.

Example:

[admin]
allowed_emails = ["alice@example.com", "ops@example.com"]

A config allowlist (rather than a Kratos identity-schema role) keeps admin membership declarative and reviewable in version control. The trade-off is that adding or removing an admin requires a config reload rather than a database write; for the small operator pool this is aimed at, that’s a feature.

Admin access model

There are two tiers of admin access. Same /admin/* URL prefix, different gates, different blast radius. This trips up operators who assume [admin].allowed_emails covers everything under /admin/* — it doesn’t.

Tier 1 — Forseti-wide admin (operator). Reached by hitting /admin/... with no ?org=<slug> query parameter. This is the surface that touches every identity, every Hydra client, every session, every audit row across the deployment. Gated by:

  1. Active Kratos session. Anonymous requests are 303-redirected to /login?return_to=... so the user lands back on the admin page after signing in.
  2. Email allowlist. The session’s traits.email must appear in [admin].allowed_emails. Non-allowlisted users get a 403 page (rendered inside the admin shell so the rejection is unambiguous).
  3. AAL2. Single-factor sessions are 303-redirected to /login?aal=aal2&return_to=..., forcing Kratos to demand a second factor before granting access.

The order matters: a non-allowlisted user with a valid AAL2 session still gets a 403. An allowlisted user with an AAL1 session is bounced to step-up before being told they’re allowed in.

Tier 2 — Org-scoped admin (org owner). Reached by hitting /admin/...?org=<slug>. This is the surface an org owner uses to manage their own org — members, branding, invites, the org-scoped audit feed. Gated by:

  1. Active Kratos session — same as Tier 1.
  2. Org ownership. The caller must be an owner of the org named by <slug> (i.e. an organization_members row with role = 'owner'). Non-owners — including members with the member role and Forseti-wide admins who aren’t members of that specific org — get a 403.
  3. AAL2 — same as Tier 1.
  4. Orgs license — only for non-Default orgs. The Default org’s admin surface stays OSS-tier; additional orgs are a commercial feature and a missing/expired license renders the upsell page instead.

[admin].allowed_emails is not checked on Tier 2. This is deliberate: org owners need to manage their own org without the operator having to add every customer’s email to the allowlist. The trust boundary on Tier 2 is “you own this org”, not “the operator vouches for you”.

What this means in practice:

  • A Forseti-wide admin (allowlisted email) who is not a member of acme-corp gets 403 on /admin/identities?org=acme-corp. The operator allowlist doesn’t grant org-owner privileges; it grants global-operator privileges, which are a different thing.
  • An org owner who is not on [admin].allowed_emails can manage their own org but cannot access /admin/identities (no ?org=), /admin/clients, /admin/license, etc. They see a 403 on the global surfaces.
  • If you want to restrict who can own an org — e.g. only allow paying customers — gate org creation, not the admin path. Org creation today goes through /orgs/new and is itself gated by the Orgs license; layer additional checks at the creation handler or via your billing flow.

The two-tier code lives at src/admin/mod.rs::require_admin (Tier 1) and src/admin/mod.rs::require_admin_with_scope (Tier 1 + Tier 2 routed by ?org=).

Admin pages

PathPurpose
/admin/admin/statusLanding redirect.
/admin/statusKratos + Hydra health probes, courier queue (pending / failed counts), build versions, and audit-health counters (write failures + the two audit-webhook counters described below).
/admin/clientsList Hydra OAuth2 clients, filter by name.
/admin/clients/newCreate a new OAuth2 client. Returns to the show page with a one-time secret + registration access token reveal.
/admin/clients/{id}View / edit a client. Rotate-secret and delete confirm pages live under here.
/admin/identitiesList Kratos identities, filter by email (Kratos credentials_identifier).
/admin/identities/{id}View identity traits, credentials, verifiable addresses, and recent sessions. Trigger recovery codes, disable / enable, or delete from here.
/admin/sessionsList every active session across all identities. Toggle “active only” and revoke individual sessions.
/admin/auditAppend-only audit event log. Filter by email substring, action prefix, severity, and since timestamp. Backed by the Forseti-owned audit_events table (sqlite or Postgres); retention is operator-configured via [audit].audit_retention_days and pruning runs through the forseti audit-prune CLI subcommand (not auto-run inside the HTTP server).
/admin/audit/{id}Full detail page for a single audit row — actor, target, metadata, IP hash, user agent.
/admin/webhooksDead-lettered account-deletion webhook rows (12 attempts or 72 h exhausted). Per-row “Requeue” and “Discard” actions; a count banner surfaces on /admin/status when the table is non-empty.
/admin/webhooks/{id}Full detail page for a dead-lettered webhook row — payload, attempt history, last error.
/admin/webhooks/{id}/requeuePOST — flip a DEAD row back to CONFIRMED so the background worker picks it up again.
/admin/webhooks/{id}/discardPOST — drop the row without further delivery attempts.
/admin/dcr-tokensList Initial Access Tokens for POST /oauth2/register. Issue / revoke from here.
/admin/dcr-tokens/{id}/revokePOST — revoke an IAT. Future registrations presenting it return 401 with iat_exhausted.
/admin/licenseView current license status (Unlicensed / Active / Grace / Expired), tier, expiry. Activate or deactivate from here.
/admin/license/activatePOST — verify a pasted signed license blob against the baked-in Ed25519 pubkey and persist.
/admin/license/deactivatePOST — drop the current license row. Premium features fall back to the upsell page.
/admin/hostsEnrolled Linux hosts. Enroll a new host (one-time host_id:secret reveal), rotate its secret, or revoke it. See Linux authentication.
/admin/posixPOSIX accounts. Provision a Kratos identity into a Linux account, manage its SSH keys, enable/disable/delete. Shows the current seat count against the cap.

App templates

/admin/clients/new shows a “Popular apps” group below the five base client types. Picking one (GitLab, Nextcloud, Grafana, …) pre-fills the create form for that app — redirect URIs, scope, token-endpoint auth method, PKCE, and any logout/webhook URLs — so you don’t have to look up each app’s OIDC quirks.

The picker at /admin/clients/new is always the source of truth, but the bundled templates are:

CategoryApps
First-partyStackpit, Formshive, Liwan
Git, CI/CD & infrastructureGitLab, Gitea, Forgejo, Jenkins, Argo CD, Harbor, Rancher, Portainer, Proxmox VE, NetBox
Files, media & knowledgeNextcloud, Seafile, Immich, Jellyfin, Audiobookshelf, Paperless-ngx, Outline, BookStack, HedgeDoc
Collaboration & productivityMatrix Synapse, Discourse, Rocket.Chat, Mattermost*, OpenProject*, Plane*, Vikunja, Mealie, Penpot, WordPress
Data, monitoring & feedsGrafana, Apache Superset, Matomo, Miniflux, Open WebUI, Parseable
OtherMastodon, Vaultwarden, Actual Budget, Atlassian Data Center*

* OIDC login requires that app’s paid/enterprise tier — the template still works, but the form’s guidance banner flags the licensing requirement.

The pre-filled URLs use literal placeholders you must replace before saving:

  • YOUR_DOMAIN — the app’s own hostname (e.g. git.example.com), not Forseti’s. Several apps embed it in a fixed callback path.
  • PROVIDER_NAME — for apps where the callback path includes the provider/auth-source name you configure app-side (Gitea, Forgejo, Vikunja, Paperless-ngx, Jellyfin). Replace it with whatever name you set there; some apps are case-sensitive about it.

Some templates carry a guidance banner on the form (e.g. PROVIDER_NAME notes, audience allow-list reminders) — read it before saving.

The template choice doesn’t change the client’s type: the stored client_type records the base preset (e.g. web_app), so the list filter and detail-page badge are unaffected by which app you started from. The template slug itself is recorded Forseti-side (purely so the app’s logo can appear next to the client on the list) — it carries no trust or behaviour, and only clients created from a template after this shipped will show a logo.

After creating a client, its detail page (/admin/clients/{id}) shows a “Connection details” card with the issuer and OIDC endpoints (authorization, token, userinfo, JWKS, end-session) plus the client ID — everything you paste into the app’s OIDC settings on the other end. The endpoints come from Hydra’s discovery document; if Forseti can’t reach Hydra at render time the card hides the endpoints and shows a note rather than guessing a (possibly wrong) issuer.

Audit logging

Audit events are persisted to the Forseti-owned audit_events table (sqlite or Postgres). The table is append-only at the DB layer — a BEFORE UPDATE/DELETE trigger refuses modifications unless the pruner sets a single-transaction override flag (current_setting('app.audit_purge') on Postgres, a sentinel row in _forseti_meta on sqlite). The flag is defence against application-bug clobbering history, not against a malicious operator with direct DB access.

Three sources feed the table:

  1. Forseti-owned handlers — direct emit. Logout, settings session revoke, OAuth consent (granted / denied), account self-deletion, every admin action (/admin/clients/*, /admin/identities/*, /admin/sessions/*, /admin/webhooks/*).
  2. Kratos flow webhooks delivered to POST /internal/audit/kratos on the internal listener. Flow-completion events only: identity.created (registration), auth.login (login.{password,passkey} — AAL2 step-up methods intentionally don’t fire so a single sign-in produces one row, not two), password.changed (settings.password), password.recovered (recovery), verification.completed (verification), mfa.* (settings.{totp,webauthn,lookup}). Kratos’s admin API does not fire flow hooks, so admin-driven identity writes go through path 1.
  3. Hydra consent decisions emitted from Forseti’s own src/oauth/consent.rs (Hydra has thin hook surface; scraping logs is fragile).

IP pseudonymization

Audit rows store a salted hash of the client IP, not the address itself, so events from the same address correlate without retaining the address. The salt comes from [audit].ip_salt when set; when unset it is derived from [security].cookie_secret, and a boot warning reminds you of that. The derived default has one operational consequence: rotating the cookie secret also rotates every ip_hash, so rows from before the rotation no longer correlate with rows after it. Set a dedicated ip_salt (openssl rand -hex 32) to decouple audit correlation from cookie-secret rotation.

Internal listener

Machine-to-machine endpoints live on a separate HTTP listener from the user-facing Forseti: today the audit webhook receiver (POST /internal/audit/kratos) and the POSIX resolver API (GET /posix/v1/*, consumed by enrolled Linux hosts’ NSS/sshd). The split is the trust boundary — the internal listener should never be reachable from the public internet, while the public listener is built for it.

KnobDefaultWhat to set in production
[internal].bind127.0.0.1:8081Loopback when Forseti and Kratos share a host. Bind to a specific private interface (e.g. 10.0.0.5:8081) — or 0.0.0.0:8081 inside a container where the trust boundary is the docker / pod network — so Kratos in a separate container can reach it. Never expose this on a public interface.

The internal listener does not mount /readyz or /healthz; those stay on the public listener so load balancers and orchestrators don’t have to know about a second port. CSRF middleware is also not applied to the internal listener — these endpoints take JSON over POST (audit webhook) or authenticated GET (POSIX resolver), not cookie-bearing browser forms.

Remote hosts and rebinding. With the default loopback bind, only processes on the Forseti host can reach the resolver. Linux hosts elsewhere need the listener rebound to a private interface (10.0.0.5:8081) behind a firewall that admits only those hosts. Note the audit webhook and the resolver share this listener — rebinding to 0.0.0.0:8081 exposes both. The resolver authenticates each host with HTTP Basic (host_id:secret, SHA-256-hashed, constant-time compared), so its own auth holds, but the audit webhook’s bearer token ([audit].webhook_token) and a network ACL in front of the listener both matter once it leaves loopback.

Audit webhook bearer

The POST /internal/audit/kratos endpoint authenticates inbound Kratos webhooks with a shared bearer token (Authorization: Bearer <token>). Forseti reads it from [audit].webhook_token; Kratos sends it from the auth.config.value field on each web_hook in kratos.yml.

The token is mandatory. Forseti refuses to boot when webhook_token is empty (exit code 1, error on stderr): a misconfigured deployment is supposed to fail loudly at startup rather than silently accept or reject every inbound event.

To rotate, use forseti config rotate webhook-token (see Rotating the audit webhook token) rather than hand-editing both files: it stages the new token in an accept-list so Forseti keeps accepting the old one until every web_hook has picked up the new value, avoiding an audit-loss window. Hand-editing both files in one shot works too, but there’s no online-rotation path that way. Kratos’s Viper-based config loader does not support env-var overrides for fields inside arrays (see ory/kratos#2663), so the token has to be a literal value in kratos.yml, and stopping Forseti before both files agree drops every webhook delivered in between. For real production deploys, template the config through your deploy tooling (Helm’s values.yaml, Terraform, or equivalent) and source the token from your secret manager. Forseti-side value comes from config.toml (or FORSETI_AUDIT__WEBHOOK_TOKEN), where env-var binding works because Forseti’s config is a flat struct.

Audit webhook replay protection

Bearer alone lets anyone who captures a single request replay it arbitrarily later — fabricating audit history. The real guard is the internal listener plus the bearer; on top of that the receiver adds a freshness signal:

Freshness window. The shared audit_event.jsonnet template emits ctx.flow.issued_at (RFC 3339) into the body. The receiver flags payloads whose issued_at is more than 1 hour old (stale) or skewed more than 1 minute into the future (future). The window covers the longest Kratos flow lifespan (settings flows default to 1h), so a stale reading means a genuinely old timestamp — replay or clock skew — not a slow user. Flagged payloads are still recorded, with a metadata.freshness marker, and counted on /admin/status (see below). Payloads missing issued_at are written unflagged — older Kratos versions omit the field on some hooks. The window is telemetry, not a hard reject: see the response-code note below for why the receiver never drops a parseable payload.

Responses. The receiver returns 401 on a missing/wrong bearer and 204 on everything else — accepted, flagged, malformed body, or unknown action. The hooks are fire-and-forget on the Kratos side (response.ignore: true), so Kratos never reads the status; the 401/204-only scheme is defence in depth so the receiver can’t break a user’s self-service flow even if a future Kratos config regresses to a blocking hook. Failures surface out-of-band on /admin/status and in warn! logs.

Threat model: what this catches and what it doesn’t. Stripe / GitHub webhook signing computes an HMAC over the body with a shared secret, which catches both replay and tampering. Kratos’s web_hook action ships static headers only: it can’t compute an HMAC at send time. So the bearer + freshness flag is the realistic ceiling without a signing proxy. If your threat model includes a real-time MITM, terminate Kratos behind a reverse proxy that injects an HMAC header (haproxy + lua, nginx + lua, envoy + wasm) and check it in front of Forseti.

Audit webhook counters on /admin/status

Two in-process counters surface the receiver’s out-of-band failure signal. Both reset on Forseti restart — they answer “did anything odd happen since the last boot?”, not “what is the all-time total”. Non-zero values render a hint on the status page.

  • Audit webhook rejected. A payload was dropped before any row was written — either a malformed body or an unknown ?action=. A non-zero count almost always means a Kratos hook or config mismatch (e.g. an action not in the receiver’s vocabulary, or a template that emits a body the receiver can’t parse). Check the kratos audit webhook warn! log lines for the specifics.
  • Audit webhook freshness anomalies. A row was written but its issued_at fell outside the 1h freshness window — stamped stale or future in metadata.freshness. Usually a slow flow finished after the window or the Kratos / Forseti clocks have drifted. The row is still recorded; the counter is a heads-up to check for clock skew (or, rarely, replay).

Default-org floor

Default-org membership used to be driven by a second web_hook on the registration flow. That endpoint is gone — Forseti now applies the Default floor lazily, in the auto_join_default_org middleware, on the user’s first authenticated request. No webhook wiring is required for org membership; only audit needs the webhook.

The Default org is a floor, not a permanent auto-join: a user is a member of it only while they hold no other org (allowlisted operators are always in it, as owner). The lazy check is one capped lookup that returns whether the identity is already in Default and how many non-default orgs it holds; when the floor is missing it runs a serialized transaction that inserts the Default row (owner for an allowlisted email, member for a non-default-less non-allowlisted one). Joining any other org drops the floor; leaving one’s last other org restores it. See organizations internals.

The audit_metadata column is operator-readable but goes through a SafeMetadata newtype that refuses sensitive-looking keys (password, secret, token, cookie, authorization, otp, recovery). Debug builds panic on offending keys; release builds drop them and warn! so a stray credential never reaches disk silently.

Sample events:

  • oauth.client.created / oauth.client.deleted / oauth.client.secret_rotated — actor + client_id
  • admin.identity.disabled / admin.identity.deleted — actor + identity_id
  • admin.session.revoked — actor + session_id
  • account.self_deleted — actor + event_id + webhook_targets count
  • oauth.consent.granted / oauth.consent.denied — actor + client_id + scope
  • auth.logout, session.revoked, sessions.bulk_revoked — actor
  • org.invite.created / org.invite.accepted — actor + org_id + invitee email + role
  • org.member.added / org.member.removed / org.member.role_changed — actor + identity_id + org_id (+ new role for role_changed)
  • identity.created, auth.login, password.changed, password.recovered, verification.completed, mfa.* — flow-driven, delivered via Kratos webhook

Retention

Default 90 days, overridable via [audit].audit_retention_days. Pruning is not auto-run inside the HTTP server — operators schedule the forseti audit-prune subcommand via cron / pipeline:

# In a systemd timer or cron, daily at 03:15 UTC:
forseti audit-prune

The subcommand reads the same config.toml as the running server, runs migrations idempotently (so a fresh box that never ran the server still works), then deletes rows older than audit_retention_days inside a single transaction with the trigger override engaged.

Known limitations

  • No granular roles inside a tier. All Tier-1 allowlisted admins have identical privileges across the Forseti-wide surface; all Tier-2 org owners have identical privileges within their org. No read-only or per-surface scoping. Use Kratos’s own access logs and the audit feed for fine-grained attribution.
  • Tier-1 allowlist is global. A single [admin].allowed_emails controls operator access for the whole deployment; there’s no per-realm partition. For per-customer scoping, use Tier 2 (org-scoped admin) instead (see Admin access model).
  • No CSV / JSON export. Audit and identity lists render only in the UI for now.
  • No tamper-evidence (hash chain). Append-only is enforced by the trigger; for stronger guarantees ship the row stream to an S3 archive with object-lock externally.
  • OIDC sign-ins are unaudited by default. A config init-generated kratos.yml carries no audit web_hook nodes, so forseti config oidc enable has no existing hook to clone onto the new provider’s login/registration flows and warns rather than silently leaving a gap (see Enabling and disabling OIDC providers). Wiring one up is a manual step today.

Linux authentication

Forseti can back the login accounts on your Linux hosts. Instead of maintaining /etc/passwd, /etc/group, and per-user ~/.ssh/authorized_keys by hand on every box, you provision a Kratos identity into a POSIX account once, and enrolled hosts resolve that account — uid/gid, login shell, home dir, and SSH keys — over a small HTTP API. The identity store stays the source of truth; a host is just a consumer.

This is the server side. The NSS/PAM client and the sshd / Guix wiring that actually plug a host into the resolver ship as the forseti-unix host client (under forseti-unix/, packaged for Guix in infra/guix/) — see Connecting a host below.

Trust model

The resolver lives on the internal listener ([internal].bind, default 127.0.0.1:8081), the same loopback-by-default port as the audit webhook — see Internal listener for the binding rules and the firewall warning. The short version: with the default bind only processes on the Forseti host reach it; remote hosts need the listener rebound to a private interface behind a firewall that admits only those hosts, and rebinding exposes the audit webhook on the same port.

Each request authenticates with the enrolled host’s host_id:secret over HTTP Basic (the secret is stored SHA-256-hashed and compared in constant time). That credential is the only thing standing between a caller and your directory once the listener leaves loopback, so treat the network ACL in front of it as load-bearing, not optional. The resolver flow and route table are in docs/dev/flows.md → POSIX resolver API.

[posix]

Account-materialisation knobs plus the interactive PAM device-auth settings. The defaults work out of the box; for the resolver you’ll typically only touch default_shell (it’s OS-specific) and free_seats. The device-auth keys (everything below free_seats) only matter once you enable PAM login.

KeyTypeDefaultDescription
uid_baseu321000000First uid handed out. Accounts allocate monotonically upward from here, and ids are never reused.
gid_baseu322000000First gid handed out for auto-created user-private groups. Deliberately disjoint from the uid space so uids and gids never numerically collide.
user_uid_sizeu321000000Size of the user uid band [uid_base, uid_base + user_uid_size).
user_gid_sizeu321000000Size of the user-private gid band [gid_base, gid_base + user_gid_size).
group_gid_baseu323000000First gid handed out for team groups. The team-gid band must not overlap the user-private gid band (Forseti refuses to boot if they collide).
group_gid_sizeu321000000Size of the team-gid band [group_gid_base, group_gid_base + group_gid_size).
default_shellstring"/bin/sh"Login shell written onto a new account unless overridden per account. OS-specific — /bin/bash on Debian, /run/current-system/profile/bin/bash on Guix System. /bin/sh is the safe default because Guix has no /bin/bash.
home_prefixstring"/home"Home dir is {home_prefix}/{username} unless overridden per account.
free_seatsu3225Free-tier seat cap — how many enabled accounts you can provision without a commercial license. See Seat cap.
pam_client_idstring"forseti-linux-pam"The confidential OAuth client id Forseti drives the device grant as for PAM login. Created (if absent) by forseti posix-init-client.
pam_client_secretstring(unset)client_secret_basic secret for pam_client_id. Leave unset to let posix-init-client mint one (revealed once). Device-auth hard-fails while this is unset/empty — see Enabling PAM login.
device_poll_cap_secsu6490Hard wall-clock cap (seconds) on a single device-auth poll loop. Keep it strictly below sshd’s LoginGraceTime (default 120s) so an abandoned login can’t pin the session. Forseti returns it so the daemon can bound its own polling.
id_token_iat_window_secsu64120iat freshness window (seconds) for the device id_token — rejects a token whose iat is older than this. A tight replay guard layered on top of exp.
mfa_auth_time_window_secsu64300auth_time freshness window (seconds) for force_mfa hosts. An AAL2 session older than this won’t unlock such a host — an hours-old MFA shouldn’t grant a login.
hydra_issuerstring(unset)Expected iss on the device id_token. Unset falls back to [hydra].public_url. Override when Hydra’s own urls.self.issuer differs from that URL — see the gotcha below.
[posix]
uid_base = 1000000
gid_base = 2000000
user_uid_size = 1000000
user_gid_size = 1000000
group_gid_base = 3000000
group_gid_size = 1000000
default_shell = "/bin/sh"
home_prefix = "/home"
free_seats = 25

# Device-auth (PAM login) — only needed once you enable interactive login.
pam_client_id = "forseti-linux-pam"
# pam_client_secret = "..."          # mint via posix-init-client
device_poll_cap_secs = 90
id_token_iat_window_secs = 120
mfa_auth_time_window_secs = 300
# hydra_issuer = "http://localhost:4444"

The picked uid/gid bases sit well above the system range so Forseti-managed accounts never clash with packages that create their own service users.

Three numeric bands carve up the space: user uids [uid_base, uid_base + user_uid_size), user-private gids [gid_base, gid_base + user_gid_size), and team gids [group_gid_base, group_gid_base + group_gid_size). The two gid bands must be disjoint, and Forseti validates this at startup, refusing to boot if they overlap, because a team gid colliding with a user-private gid would silently cross-grant file access. Ids are allocated monotonically and never reused (tracked in the posix_sequences table): a reused uid/gid would silently reassign ownership of files left on disk or in backups by a deleted account.

Enrolling a host

A host has to identify itself to the resolver before it can resolve anything.

  1. Go to Admin → Hosts (/admin/hosts), then New (/admin/hosts/new).
  2. Name the host and submit. Forseti mints a host_id and a secret and shows the combined host_id:secret once. Copy it now — it’s not stored in retrievable form and you can’t see it again.
  3. Put that credential into the host client’s config — the host-id / host-secret fields of the forseti-unix-configuration (see Connecting a host). For a manual check, it’s the HTTP Basic username:password the resolver expects.

Rotating a host’s secret: Admin → Hosts → the host → Rotate (/admin/hosts/{id}/rotate). This mints a fresh secret, reveals it once, and invalidates the old one immediately — so the host is locked out until you update its config. Rotate on a schedule, or right away if a host’s credential might have leaked.

Editing a host: Admin → Hosts → the host → Edit (/admin/hosts/{id}/edit). Change the display name, the force_mfa flag, or the team scope (which of the org’s teams the host resolves, covered below) after enrollment. A host’s organisation is fixed at enrollment and can’t be changed here; re-enroll under the right org if that has to change.

Revoking a host: Admin → Hosts → the host → Revoke (/admin/hosts/{id}/revoke). The host can no longer resolve anything. Use this when you’re decommissioning a box.

force_mfa is enforced on the PAM device-auth login path. The enroll form captures a force_mfa flag against the host, and it’s a real control on the interactive PAM login — it does not gate the NSS resolver (resolving an already-provisioned account is never MFA-gated). For a force_mfa host, Forseti only tells the host approved when the approving session is a fresh AAL2 login: the id_token’s acr must be aal2, its amr must carry a real second factor (TOTP, WebAuthn, or a recovery code — a password alone never counts), and its auth_time must fall within mfa_auth_time_window_secs (default 300s) so an hours-old MFA can’t unlock a login. Forseti also suppresses the one-click verification_uri_complete link for these hosts, so the human has to type the user code by hand.

Provisioning an account

Enrolling a host gives it the right to resolve; provisioning is what creates something to resolve.

  1. Go to Admin → POSIX accounts (/admin/posix), then New (/admin/posix/new). This is a two-step, no-JS flow.
  2. Pick the identity. Either click Select user to open the identity picker (a searchable, org-scoped list of identities — each row has a Select link that returns you to the form with that identity filled in), or type a Kratos identity UUID or an email address into the field. A typed email is resolved to its identity against Kratos at submit time. Identities that exist only via OIDC/SAML may not resolve by typed email — for those, use the picker; it’s the reliable path.
  3. Set the account details. Once an identity is chosen the form shows its email read-only and carries the resolved UUID. A username suggestion derived from the email’s local-part is pre-filled and editable. uid, gid, login shell, and home dir default from [posix] (uid/gid auto-allocated, shell/home derived) — override them on the form if a particular account needs something specific. The login shell must exist on the device(s) that serve this account; /bin/sh is the safe cross-distro default (Guix has no /bin/bash).
  4. Submit. Forseti creates the POSIX account plus its primary group.

On the account page (/admin/posix/{id}):

  • Add SSH keys — paste a public key (/admin/posix/{id}/keys). The resolver serves these to sshd’s AuthorizedKeysCommand. Remove a key from the same page (/admin/posix/{id}/keys/{key_id}/delete).
  • Disable / enable — toggle the account (/admin/posix/{id}/disable, /admin/posix/{id}/enable). A disabled account stops resolving (no login, no keys) but its row, uid/gid, and keys are retained, so enabling it again restores the same identifiers. Disabling frees a seat — a disabled account doesn’t count against the cap.
  • Delete (/admin/posix/{id}/delete) — remove the account and its POSIX rows outright. Deleting the underlying Kratos identity also purges its POSIX rows at every delete path (admin delete, self-service account deletion, the unverified-prune reaper), and an hourly reconcile sweep catches identities deleted out-of-band via the Kratos admin API — so an orphaned POSIX account can’t keep a deleted identity’s login alive.

Seat cap

Provisioning a new enabled account consumes a seat. The cap depends on your license state:

  • No license (OSS): up to [posix].free_seats enabled accounts (default 25).
  • Commercial license with Linux authentication: the license’s max_seats raises the cap. Provisioning beyond it is blocked with a clear message naming the current count and cap.
  • Grace window: a license that’s expired but still in its 30-day grace period falls back to the free cap for new provisioning — provisioning a new account is a write, and grace is read-only for writes. Existing accounts keep working.
  • Resolution is never gated. A host can always resolve an already-provisioned account, regardless of license state. A lapsed or missing license can stop you adding accounts; it can never lock an existing user out of a machine they already log in to.

Disabling an account frees its seat (see above); deleting one frees it too. The list page (/admin/posix) shows the current enabled / cap count so you can see how much headroom you have.

Each host belongs to one organisation (set at enrollment). You can scope a host to the whole org (it resolves all of that org’s provisioned members) or to specific teams within the org (it resolves only those teams’ members). Team membership is resolved live by the resolver at request time — there is no mirroring step, so changes take effect on the next lookup. Creating and managing teams requires the commercial Organizations feature; without it a host resolves its org as a whole. Provisioning a POSIX account always also creates that account’s own primary group regardless of license.

Enabling PAM login (device-auth)

The resolver hands a host the shape of an account (uid/gid, shell, home, keys). Interactive password/console login — ssh with a password, a TTY login, sudo re-auth — is a separate path built on the OAuth 2.0 Device Authorization Grant (RFC 8628). The host’s PAM module starts a device flow for the named account, the human approves it in their browser, and Forseti binds the approving identity to the named account before the host is told the login is approved. The full mechanism is in docs/dev/flows.md → POSIX device-auth login.

This path needs one extra thing the resolver doesn’t: a confidential OAuth client Forseti authenticates as when it drives the device grant through Hydra.

  1. Mint the client. Run

    forseti posix-init-client
    

    This creates the forseti-linux-pam confidential client in Hydra (if it doesn’t already exist — it never overwrites one you’ve tuned) and prints the freshly-minted client_secret once. Hydra won’t show it again.

  2. Store the secret. Put that value into [posix].pam_client_secret. (If you’d rather supply your own secret, set it in config first and posix-init-client will use it instead of minting — it won’t echo a secret you already hold.)

Device-auth hard-fails while pam_client_secret is unset or empty. A request to the device-auth endpoints in that state logs an error, returns 500, and makes no call to Hydra — an empty secret would send client_secret_basic with a blank password, which Hydra rejects with a confusing 502, so Forseti refuses up front. Set the secret before pointing any host’s PAM stack at Forseti.

hydra_issuer gotcha

The device id_token’s issuer (iss) must match what Forseti expects, or validation fails with InvalidIssuer and every login is denied. By default Forseti expects [hydra].public_url. But Hydra advertises whatever its own urls.self.issuer is set to, which is not always the same string — the playground, for instance, issues tokens with host.containers.internal:4444 while public_url is localhost:4444. When the two differ, set [posix].hydra_issuer to Hydra’s actual issuer.

Connecting a host

The host-side piece — the NSS module, the daemon, the sshd AuthorizedKeysCommand hook, and the pam_forseti.so PAM module that drives device-auth login — ships as the forseti-unix client workspace (under forseti-unix/), packaged for GNU Guix. On a Guix System you wire it in with one service plus the system-wide name-service-switch; everything else (the daemon account, the runtime directories, the pam_mkhomedir session entry, the nscd module load) is handled by the service.

The package and service split across two places:

  • The forseti-unix package (forseti-unixd, libnss_forseti.so.2, forseti_ssh_authorizedkeys) lives in the panther channel as forseti-unix in (px packages authentication). It carries the generated ~190-crate set so it builds offline; the earlier in-repo stub couldn’t and has been removed.
  • infra/guix/forseti-unix-service.scmforseti-unix-service-type (defaults to panther’s package), plus the ready-made %forseti-name-service-switch and %forseti-nscd-caches values you drop into your operating-system.

Minimal operating-system wiring:

(use-modules (forseti-unix)          ; the package
             (forseti-unix-service)) ; the service + nss/nscd helpers

(operating-system
  ;; …
  ;; Chain `forseti' after `files' for passwd/group. REQUIRED — without this
  ;; nscd loading the module does nothing; nsswitch must list it.
  (name-service-switch %forseti-name-service-switch)
  (services
   (cons*
    (service forseti-unix-service-type
             (forseti-unix-configuration
              (server-url "https://id.example.com")
              (host-id "host-abc")          ; from `/admin/hosts` enrollment
              (host-secret "REDACTED")))    ; the one-time secret reveal
    (service openssh-service-type
             (openssh-configuration
              ;; HARD PRECONDITION: pam_mkhomedir only runs under PAM. Without
              ;; `use-pam? #t' an SSH login never creates a home directory.
              (use-pam? #t)
              (authorized-keys-command
               (file-append forseti-unix "/bin/forseti_ssh_authorizedkeys"))
              (authorized-keys-command-user "forseti")))
    ;; Lower nscd's passwd/group positive TTL so it doesn't shadow the daemon's
    ;; own cache TTL with a long stale window.
    (modify-services %base-services
      (nscd-service-type config =>
        (nscd-configuration (inherit config) (caches %forseti-nscd-caches))))
    ;; … the rest of %base-services / %desktop-services
    )))

The credential from step 3 above (host_id + host_secret, the one-time reveal at /admin/hosts) goes into the forseti-unix-configuration. The service renders /etc/forseti/unixd.toml from those fields (tightened to 0600, owner forseti), runs forseti-unixd as the unprivileged forseti user, and adds the NSS module to nscd. See the header comment block in infra/guix/forseti-unix-service.scm for the full mechanism notes.

End-to-end (getent / id / key-based ssh landing in a pam_mkhomedir home) is the deferred Layer-5 test — it needs a full guix system vm and a live enrolled host, so it isn’t part of CI. The VM smoke procedure is in infra/guix/README-linux-auth.md.

A forseti-unixd outage denies Forseti users but never your local ones. The service installs pam_forseti.so as the sole arbiter of the account stack for Forseti (NSS-only) accounts with an explicit control map. When the daemon is unreachable, a Forseti user’s account check returns PAM_AUTHINFO_UNAVAIL, which the control map maps to die — they cannot log in (fail-closed). A genuine local, shadow-backed account (root and friends) is classified by a /etc/shadow lookup and returns PAM_IGNORE, so it falls through to pam_unix and logs in normally (fail-open). So an outage fails closed for Forseti users and fail-open for local ones — you don’t get locked out of your own boxes, but a directory outage does stop directory-backed logins. The exact PAM control-map detail is in infra/guix/README-linux-auth.md.

Offline authentication

The device-auth login above needs the network — it drives a browser device grant against Forseti. Offline authentication is an opt-in fallback for the case where a host’s daemon is up but cannot reach Forseti (a laptop on a plane, a datacenter partition, Forseti maintenance): the host authenticates the user at the terminal against a dedicated offline passphrase they set earlier while online. Online device-auth is always preferred and always wins; offline is only offered when the server is genuinely unreachable.

This is not the same as the daemon being down. A forseti-unixd outage stays fail-closed (above) — offline auth needs the daemon running to verify the passphrase. It only kicks in on server-unreachable, daemon-up.

How a user enables it. While online, the user sets a passphrase at /settings/offline-access in their dashboard. It must be at least 8 characters and is separate from their Forseti account password — it’s a dedicated offline credential, never the primary one. Forseti stores only an Argon2id verifier (m=64 MiB, t=3, p=1); enrolled hosts pull it on an interval and re-pepper it locally. Clearing the passphrase there withdraws it from every host on their next sync.

Each user’s first login on a host must be online. A host is provisioned offline verifiers only for the accounts it has already seen: a user’s verifier is served to a given host only after that user has completed an online device-auth login on that host. On a freshly enrolled host every user therefore has to log in once while the host can reach Forseti; every later login on that host can fall back to offline. The point is to bound what a compromised or decommissioned-but-unrevoked host can walk away with: an offline-crackable corpus for the people who actually use that host, not for the whole org. The record is per host and does not expire on its own (expiring it would lock a returning user out of a partitioned host), so withdrawal is deleting the host, disabling the account, or de-scoping it, exactly as with any other verifier.

force_mfa hosts refuse offline auth. A host enrolled with force_mfa is provisioned zero offline verifiers — it always requires the network to log a user in. This is deliberate: it closes the AAL2-downgrade where a user could skip their second factor simply by going offline. If you depend on MFA at a host, leave force_mfa on and accept that a partition means no terminal login there.

Reduced guarantee — state it plainly. Offline auth is a weaker control than online device-auth, by construction. The host keeps its HMAC pepper in a 0600 file, so a stolen host disk or a stolen server DB permits an offline brute-force of the passphrase — bounded only by the Argon2id work factor times the passphrase’s entropy. That’s the whole reason for the 8-character floor. The per-user host lockout (offline_lockout_max) defends live terminal guessing only, not someone who walks off with the disk. TPM sealing (M3b) — which makes the verifier uncheckable off the host and is the planned hardening — is not in this release. Until then, treat a host that holds offline verifiers as carrying brute-forceable secrets at rest, and keep offline passphrases strong.

Revocation latency. Each provisioned verifier carries a TTL (offline_ttl_hours, default 24). A disabled, de-scoped, deleted, or passphrase-cleared user drops off the host’s next pull — but on a fully partitioned host that pull may not happen, so the worst-case window between disabling an account and its offline credential becoming unusable is offline_ttl_hours. A second hard cap (offline_max_lifetime_secs, default 168h, measured from the last successful online login) bounds it regardless of TTL refreshes. Offline-auth attempts are queued on the host and flushed into the server audit log on reconnect — so the events aren’t lost, just delayed until the partition heals.

The full mechanism (the server-unreachable trigger, the gate, the host keystore, the explicit non-goals) is in docs/dev/flows.md → POSIX offline auth.

Server config[posix]:

KeyTypeDefaultDescription
offline_auth_enabledbooltrueMaster switch. When off, no verifiers are provisioned and /settings/offline-access 404s.
offline_ttl_hoursu6424TTL stamped on each provisioned verifier. Bounds the offline window since the host’s last poll — and the worst-case disable-to-revocation latency on a partitioned host.
offline_max_lifetime_hoursu64168Hard cap (from the last successful online auth) on how long a host may keep using an offline credential, regardless of TTL refreshes.
offline_min_lenusize8Passphrase length floor, enforced server-side. Never honoured below the hard wall of 8.

Host config — the forseti-unix client’s flat TOML (rendered by the Guix service into /etc/forseti/unixd.toml):

KeyTypeDefaultDescription
credentials_dbstring/var/lib/forseti/credentials.dbPath to the forseti-unixd-owned 0600 offline keystore (re-peppered verifiers, lockout, audit queue, host pepper).
offline_lockout_maxu325Consecutive offline failures before a per-user lockout (live-guessing defence only).
offline_poll_secsu64300How often the daemon pulls the current verifier set and flushes queued audit events.
offline_max_lifetime_secsu64604800Host-side hard ceiling (from the last successful online auth) on an offline credential’s age. Mirror of the server’s offline_max_lifetime_hours.

Two-factor authentication enforcement

This is the section to read carefully if you run your own Kratos. 2FA enforcement lives in your Kratos config, not in Forseti code — and if you get it wrong, the second factor becomes a decoration that anyone with the password (or a recovery email) can walk straight past.

Operator responsibility — read this. Forseti does not, and cannot, enforce 2FA on its own self-service surface. Enforcement is two knobs in your kratos.yml. If those knobs are at aal1, there is no 2FA enforcement at all, and worse, the factor-removal bypass below is wide open. Forseti has no way to verify your live Kratos config — Kratos’s API exposes only a version string and an opaque config hash, not the actual settings — so it can’t warn you. You own this. The reference playground (infra/kratos/kratos.yml) ships with both knobs set correctly; if you copy from it you’re fine. The one thing Forseti enforces regardless of Kratos config is its own admin surface (/admin/*), which does an independent AAL2 check in code.

It can’t read your live config, but it can lint the config files — that’s what forseti config-check is for. Point it at your kratos.yml and it’ll tell you whether these two knobs (and a handful of related ones) are set the way they should be. See Config CLI below; running it in CI is the cheapest insurance against shipping a misconfigured Kratos.

The two knobs

Both of these must be highest_available. Not one. Both.

# kratos.yml
session:
  whoami:
    # Any identity with a second factor enrolled must complete AAL2 before
    # whoami returns a session. Kratos answers 403 for an AAL1 session;
    # Forseti maps that to a /login?aal=aal2 step-up. Users with NO second
    # factor are unaffected — they stay at AAL1 and never see a prompt.
    required_aal: highest_available

selfservice:
  flows:
    settings:
      # Changing or removing a second factor (or the password) requires AAL2.
      # This is the critical one. See "Why both" below.
      required_aal: highest_available

highest_available means “the highest AAL the identity could satisfy”. A password-only user can only reach aal1, so they’re held to aal1 — no second factor is demanded of someone who never enrolled one. The moment a user enrols a second factor, their “highest available” becomes aal2, and from then on both gates demand it.

Why both — the factor-removal bypass

whoami.required_aal alone looks like it’s enough: it forces enrolled users to step up before they can see any protected page. It isn’t enough.

Consider settings.required_aal: aal1 while whoami.required_aal: highest_available. An attacker (or a user who recovered via email) holds an AAL1 session — password-only, or a fresh email-recovery session. They can’t view the dashboard (whoami 403s them). But they can open the settings flow, because settings only demands aal1. From there they remove the second factor. Now their identity’s “highest available” drops back to aal1, whoami stops 403-ing, and they’re fully in — 2FA defeated without ever presenting the second factor.

Email recovery is the realistic version of this attack: anyone who controls the inbox could otherwise strip 2FA. Setting settings.required_aal: highest_available closes it — an AAL1 session cannot touch credentials (2FA or password) until it steps up to AAL2 first. In normal use this adds no extra prompt, because an enrolled user is already AAL2 by the time they reach settings (they stepped up at login). It only ever blocks an un-stepped-up session.

Behavior summary

SituationWhat happens
Login, user with no second factorPassword → AAL1. Stays AAL1, full access. No prompt.
Login, user with a second factorPassword → AAL1, then any protected page bounces to /login?aal=aal2 → complete the second factor → AAL2 → access. Once per session.
OAuth login through Forseti’s bridge, enrolled userSame step-up is forced even if the relying party didn’t ask for acr_values=aal2. The whoami 403 catches it.
Managing factors at /settings/2fa, or changing the passwordRequires AAL2.
/admin/*Independent AAL2 check in Forseti code — enforced regardless of Kratos config.

The step-up is a one-time event per session: the user clears it once, the session is AAL2, and they don’t see it again until the session ages out.

Break-glass and recovery

This is the subtle part. Get the recovery model wrong and you’ll lock users out — or leave a hole.

Recovery codes are the only portable AAL2 factor. TOTP and WebAuthn are tied to a device; lose the device and they’re gone. Kratos lookup_secret recovery codes are not — a code satisfies AAL2 from any browser. So they’re the lifeline for a lost-device user. Forseti pushes hard for them: a warning banner on /settings/2fa and a notice on the dashboard appear whenever a user has a device factor (TOTP/WebAuthn) but no recovery codes. It’s a strong nudge at enrollment, not a hard per-request gate — so make sure your users act on it.

Lost device, has recovery codes. Log in with the password (AAL1) → step up at /login?aal=aal2 using a recovery code instead of the missing device → in settings, remove and re-enrol factors. Self-service, no operator involvement.

Forgot password, 2FA user. Email recovery alone does not bypass 2FA — that’s the whole point of settings.required_aal: highest_available. The recovered session is AAL1, so it can’t reset the password until it steps up. The path is: email recovery → step up with the second factor or a recovery code → reset the password. Forseti preserves the focused password-reset page across the step-up by keeping the ?flow= in the step-up’s return_to, so the user lands back on the password form after clearing AAL2, not on a generic page.

Lost device, no recovery codes, forgot password. This user is locked out of self-service — by design. They have zero factors they can present, so there is nothing to recover with; that’s exactly the property 2FA is supposed to have. The escape hatch is an admin-minted recovery link or code: POST /admin/recovery/link (driven from the admin identity page, /admin/identities/{id}). The operator hands it over out-of-band, the user completes a recovery flow, and re-enrols. This is why forcing recovery codes matters — every user without them is a future support ticket that only an admin can resolve.

Config CLI

Forseti’s 2FA enforcement lives entirely in Kratos config, and Kratos won’t tell you over the wire whether you got it right. So Forseti ships subcommands that work on the config files directly: no DB, no running server, no Ory clients. They’re pure file operations. config-check and config-init (below) started as standalone subcommands and are now also reachable as forseti config check / forseti config init under the unified forseti config surface. Both spellings work; the top-level ones are kept as hidden aliases for backward compatibility. See Managing configuration with forseti config for the rest of that surface: enabling/disabling OIDC providers, rotating secrets, SMTP, backups.

Every subcommand takes --help (also -h), and forseti --help lists them all. Running forseti with no subcommand starts the HTTP server.

config-check

Lints an existing Kratos + Hydra config against Forseti’s recommendations and prints a finding per check, grouped by file:

forseti config-check                                   # uses the discovery order below
forseti config-check --kratos /etc/kratos/kratos.yml --hydra /etc/hydra/hydra.yml
forseti config-check --strict                          # also fail the run on WARN, not just FAIL

How it finds your config. Each file is resolved independently, highest precedence first:

  1. the --kratos / --hydra flag,
  2. the FORSETI_KRATOS_CONFIG / FORSETI_HYDRA_CONFIG env var,
  3. the dev default (infra/kratos/kratos.yml / infra/hydra/hydra.yml) — but only if that file actually exists.

If none of those resolves to a file, config-check doesn’t silently proceed — it prints a clear error naming the missing config (e.g. No Kratos config found. Pass --kratos <path> or set $FORSETI_KRATOS_CONFIG.) and exits non-zero. The output header shows the resolved path and where it came from, so you can always see exactly which file was linted and why:

== Kratos (/etc/kratos/kratos.yml — from --kratos) ==

Each line is [ OK ] / [WARN] / [FAIL] followed by the key path, the current value, the recommended value, and a one-line note on what breaks if you ignore it. SMTP/DSN credentials are redacted in the output, so it’s safe to paste into a CI log. The command exits non-zero if any check FAILs (WARN alone doesn’t fail unless you pass --strict), which makes it a drop-in CI gate:

# .github/workflows/...
- run: forseti config-check --kratos kratos.yml --hydra hydra.yml

The headline checks are the two 2FA knobs from the section above: selfservice.flows.settings.required_aal at anything other than highest_available is a FAIL (it’s the factor-removal bypass), and session.whoami.required_aal not at highest_available is a WARN. It also covers recovery codes (lookup_secret), WebAuthn-as-second-factor (passwordless: false), self-service recovery, a non-placeholder SMTP URI, and the Kratos/Hydra secrets (presence, no obvious placeholders, and secrets.cipher being exactly 32 chars). On top of those specific checks it scans both files recursively and FAILs on any leftover CHANGEME_* placeholder (naming the dotted key path) — so a half-filled config-init output can’t pass.

Running it against the playground reference config as-is (forseti config-check --kratos infra/kratos/kratos.yml --hydra infra/hydra/hydra.yml) exits 1 with well over a dozen FAILs: that’s expected, not a bug. The playground ships Kratos/Hydra secrets unset and the literal dev-playground-token-change-me audit webhook bearer baked into every hook, both deliberately insecure defaults meant to be replaced before anything resembling production traffic touches the stack (see config-init or forseti config below for generating or rotating real values). Don’t be alarmed by a non-zero exit against the playground; be alarmed by one against a deployment you meant to be production-ready.

config-init

Generates a recommended Kratos + Hydra config from the known-good reference, with your URLs/DSN/SMTP substituted in and fresh secrets minted from a CSPRNG. The security recommendations are baked in regardless of input — both required_aal knobs at highest_available, recovery codes on, WebAuthn as a second factor, TOTP on, recovery enabled.

forseti config-init \
  --forseti-url https://accounts.example.com \
  --kratos-public-url https://accounts.example.com/kratos \
  --kratos-admin-url http://kratos:4434 \
  --hydra-public-url https://accounts.example.com/hydra \
  --hydra-admin-url http://hydra:4445 \
  --kratos-db-dsn 'postgres://kratos:...@db/kratos' \
  --hydra-db-dsn  'postgres://hydra:...@db/hydra' \
  --smtp-uri      'smtps://user:pass@smtp.example.com:465' \
  --smtp-from-address 'no-reply@example.com' \
  --smtp-from-name    'Example Accounts' \
  --kratos-out kratos.yml --hydra-out hydra.yml

It refuses to clobber an existing file unless you pass --force. Anything you don’t supply via a flag is written as a loud CHANGEME_* placeholder, and the command prints exactly which ones are still outstanding — so a half-filled config can’t masquerade as complete. config-check then FAILs on any leftover CHANGEME_*, anywhere in either file. The WebAuthn rp.id is derived from the host of --forseti-url (e.g. accounts.example.com), which is correct for a single-host deployment; narrow it to a registrable parent domain by hand if you serve several subdomains. With --forseti-url absent it stays CHANGEME_RP_ID and config-check FAILs on it like any other placeholder. --smtp-from-address / --smtp-from-name are optional and, when supplied, are written under courier.smtp in kratos.yml alongside connection_uri.

The generated files carry no comments — config-init and the other config subcommands round-trip these files through serde_yaml_ng, which would silently drop any comments on the next parse/write, so keeping prose in the file would be misleading. See Configuration rationale for why each baked-in recommendation is set the way it is. After writing, run the linter over what it produced to confirm the round-trip:

--force is a full regeneration, not a merge. Re-running config-init --force against an existing kratos.yml/hydra.yml does not patch the file: it renders a brand-new pair from scratch, with fresh CSPRNG secrets throughout (cookie/cipher/system secrets, and Hydra’s pairwise salt). Any OIDC providers you’d enabled with forseti config oidc enable, any flow hooks, and any rotation history (accept-lists from a prior config rotate webhook-token, multi-entry secret lists) are gone, overwritten with the from-scratch template. Only reach for --force on a config you’re deliberately starting over; otherwise use the targeted forseti config subcommands below to change one thing at a time.

forseti config-init ... --kratos-out kratos.yml --hydra-out hydra.yml
forseti config-check --kratos kratos.yml --hydra hydra.yml   # should be 0 FAIL, 0 WARN

A note on the generated secrets: they’re embedded directly in the files and grant full session/token control, so treat the output the way you’d treat any secret material — review it, lock down the file permissions, and don’t commit it.

Configuration rationale

Why config-init’s baked-in recommendations are set the way they are. This used to live as inline comments in the generated kratos.yml / hydra.yml, but those files are CLI-owned artifacts that round-trip through serde_yaml_ng on every later config subcommand, which drops comments on write — so the prose moved here instead.

Kratos session.whoami.required_aal: highest_available. highest_available forces any identity with a second factor enrolled to complete AAL2 before whoami returns a session — Kratos answers 403, which Forseti maps to a /login?aal=aal2 step-up. Settings also requires AAL2 (see below) so an AAL1 session (password-only login, or an email-recovery session) can’t strip a second factor and defeat 2FA. Lost-device users step up with a lookup_secret recovery code (which satisfies AAL2) to manage their factors.

Kratos selfservice.methods.webauthn.config.passwordless: false. This keeps WebAuthn as a second factor (AAL2). Flipping it to true makes it a first-factor login and it will not satisfy the AAL2 step-up.

Kratos selfservice.flows.settings.required_aal: highest_available. AAL2 is required for settings changes once the identity has a second factor. Otherwise an AAL1 session (password-only login, or an email-recovery session) could open the settings flow and remove the second factor, defeating 2FA entirely. With enforcement on, the user is already AAL2 by the time they reach settings (they stepped up at login), so this adds no extra prompt for normal use — it only blocks an un-stepped-up session from touching credentials.

Hydra urls.self.issuer. The issuer must be reachable under the same hostname from both the browser and any resource servers so the iss claim in id_tokens validates everywhere.

Hydra oidc.dynamic_client_registration. This is Dynamic Client Registration (RFC 7591). The portal advertises itself as the registration_endpoint and gates inbound requests with an Initial Access Token before forwarding to Hydra. See src/oauth/register.rs.

Hydra webfinger.oidc_discovery.client_registration_url. Points at the portal, not Hydra — the portal validates an Initial Access Token before forwarding to Hydra.

Hydra oauth2.pkce.enforced_for_public_clients: true. MCP 2025-06-18 requires PKCE with S256 for public clients.

Hydra strategies.access_token: jwt. Access tokens are JWTs by default. Resource servers validate locally against Hydra’s JWKS. Flip to opaque if you need immediate revocation (and route every RS to the admin API on :4445).

Managing configuration with forseti config

config-check and config-init cover linting and first-time generation. Once a deployment is live, day-2 operations (turning on a sign-in provider, rotating a secret, restoring from a backup) go through the rest of the forseti config surface. Like config-check/config-init, every subcommand here is a pure file operation: no DB, no running Forseti process, no live Kratos/Hydra API calls beyond a couple of best-effort read-only probes (counting affected identities/clients before a destructive change, when an admin URL is configured).

Bare forseti config (no subcommand) drops into an interactive menu when stdin is a TTY: it walks every setting config check knows about, lets you drill into one, and delegates to the same functions the subcommands below call. Outside a TTY (scripts, CI, systemd) it prints the subcommand help and exits 2 instead of hanging.

Subcommand overview

CommandWhat it does
forseti configInteractive menu (TTY only)
forseti config status [--json]One-line-per-setting summary: OIDC providers, secret rotation state, SMTP, webhook token
forseti config check [--strict]The linter described above
forseti config init ...The generator described above
forseti config oidc enable <google|github|microsoft> --client-id <id> (--client-secret-env/-file/-stdin) [--microsoft-tenant <id>] [--keep-mapper]Add/replace an upstream sign-in provider
forseti config oidc enable apple --client-id <services-id> --apple-team-id <id> --apple-key-id <id> (--apple-private-key-env/-file/-stdin) [--keep-mapper]Add/replace Sign in with Apple
forseti config oidc disable <id>Remove a provider
forseti config rotate webhook-tokenStage a new audit webhook token (accept-list, zero-loss)
forseti config rotate kratos-secrets [--cookie | --cipher]Prepend a new Kratos cookie and/or cipher secret
forseti config rotate hydra-systemPrepend a new Hydra system secret
forseti config rotate pairwise-salt --i-understand-subs-changeOverwrite Hydra’s pairwise salt (irreversible)
forseti config prune webhook-tokenDrop the old webhook token once every service has reloaded
forseti config prune kratos-secrets [--cookie | --cipher]Drop old Kratos secrets
forseti config prune hydra-systemDrop old Hydra system secrets
forseti config restore [--from <unix-secs>]Restore a file from its .bak.<ts> ring
forseti config smtp set (--uri-env/-file/-stdin) [--from-address] [--from-name]Set Kratos courier SMTP

Global flags, valid on every config subcommand: --kratos/--hydra (aliases --kratos-config/--hydra-config, same discovery order as config-check), --forseti-config (path to config.toml; falls back to $FORSETI_CONFIG_PATH or the dev default), --dry-run, --yes (skip confirmation prompts), --follow-symlink (operate on a symlinked target instead of refusing it).

Every mutating subcommand backs up the file it’s about to change first (see Backups and restore) and shows a redacted unified diff of what it’s about to write. Confirmation prompts before writing apply only to oidc disable, rotate/prune kratos-secrets and hydra-system, rotate pairwise-salt (which requires typing a specific phrase), and restore. Conversely, oidc enable, smtp set, and rotate/prune webhook-token write immediately without a generic gate, relying on the printed diff, backup ring, and --dry-run for preview. --yes suppresses confirmation prompts where they apply; --dry-run previews without writing. Writes are atomic (temp file + rename) and land 0600.

Enabling and disabling OIDC providers

forseti config oidc enable <provider> --client-id <id> --client-secret-env <VAR> writes the provider block into kratos.yml (literal client_id/client_secret: see the ${VAR} note under Kratos configuration → oidc above) and drops a reviewed mapper jsonnet next to it. Every pinned mapper gates the email trait on claims.email_verified; Google and Apple additionally carry that verification into Kratos, so their users skip Forseti’s own verification mail (see Which providers’ verification Forseti trusts below). The secret can come from an env var, a file, stdin, or (interactively) a masked prompt: never a bare CLI argument, so it doesn’t end up in shell history or ps. Microsoft requires --microsoft-tenant <tenant-id>; the common, organizations, and consumers pseudo-tenants are all refused (the nOAuth account-takeover class, see the note above).

Apple is the exception to the client-secret shape. Apple issues no static secret: Kratos mints one per handshake as a JWT signed with the .p8 key from the Apple Developer portal, so enable apple takes --apple-team-id, --apple-key-id, and the key itself through its own --apple-private-key-env/-file/-stdin group (no masked-prompt fallback: a PEM doesn’t survive a single-line read). --client-secret-* is refused for Apple, and the --apple-* flags are refused for everyone else. The key lands in kratos.yml as a literal PEM block, and the diff enable prints redacts it line by line. config check knows the difference too: it lints Apple’s three key fields instead of demanding a client_secret, and warns if a stale one is left behind.

If the target mapper file already exists with content that doesn’t match Forseti’s pinned body, enable refuses and asks for --keep-mapper to proceed without touching it: it won’t silently clobber a mapper you’ve customized.

The audit gap. config init-generated kratos.yml files carry no audit web_hook nodes at all (see Audit logging: the reference playground has them, a from-scratch config init doesn’t). oidc enable looks for an existing web_hook template on another flow to clone onto the OIDC login/registration flows; when it finds none, it still enables the provider but prints a loud warning that OIDC sign-ins won’t reach the audit log until a webhook is wired up by hand. This is a known, documented gap, not a bug: wiring one up requires an audit-endpoint URL and bearer token that only the operator knows.

forseti config oidc disable <id> removes the provider block (and, best-effort, reports how many existing identities look like they signed in through it, when an admin URL is configured: this is advisory, not a block on proceeding).

Rotating the audit webhook token

[audit].webhook_token authenticates inbound Kratos flow-completion webhooks (see Audit webhook bearer). The old manual procedure (stop Forseti, hand-edit both files, restart) has a hard availability trade-off: there’s no window where both the old and new token work, so any ordering drops audit events for however long it takes to update both sides. forseti config rotate webhook-token avoids that by staging the change:

  1. forseti config rotate webhook-token writes config.toml’s [audit].webhook_token as an accept-list [new, old]: Forseti will accept requests bearing either token, and only then rewrites kratos.yml’s hooks to send the new one. In interactive mode it stops and waits for you to restart Forseti before touching kratos.yml, so the accept-list is live before Kratos starts sending the new token. Non-interactively it writes both files back-to-back and prints a warning: restart Forseti immediately, since until it reloads config.toml it will 401 the new token Kratos is now sending.
  2. Restart Forseti (it doesn’t hot-reload config.toml). Kratos hot-reloads its config file on its own, so no Kratos restart is needed once kratos.yml is written.
  3. Once you’re satisfied every event source is using the new token, forseti config prune webhook-token drops the old entry from the accept-list back to a single value. forseti config check/config status report the rotation as pending for as long as the accept-list has more than one entry.

If the current token is a placeholder (CHANGEME_*) or unset, there’s nothing live to protect a rotation window for, so rotation happens in one pass with no accept-list staging.

$FORSETI_AUDIT__WEBHOOK_TOKEN shadowing. Figment layers env vars over config.toml at boot. If that env var is set, it overrides whatever [audit].webhook_token this command writes, and Forseti won’t see the accept-list until the env var is unset (or updated to match). The command detects a set env var and warns; it can’t fix it for you, since unsetting an operator’s environment isn’t something a config-file tool should touch.

Rotating Kratos and Hydra secrets

secrets.cookie/secrets.cipher (Kratos) and secrets.system (Hydra) follow Ory’s own rotation convention: the first entry in the list signs/encrypts new values, but every entry in the list remains valid to verify/decrypt existing ones. forseti config rotate kratos-secrets [--cookie|--cipher] (neither flag rotates both) prepends a fresh secret; forseti config rotate hydra-system does the same for Hydra. Kratos hot-reloads, so no restart is needed there; Hydra does not, so a Hydra system-secret rotation needs a restart before the new secret takes effect for signing (it still verifies old sessions/tokens against the full list either way).

Prune (forseti config prune kratos-secrets [--cookie|--cipher], forseti config prune hydra-system) drops everything except the current first entry, and refuses when there’s only one entry to begin with (nothing to prune). Prune secrets.cookie only after the max session lifetime has elapsed since rotation: a leaked old cookie secret can still forge sessions for as long as it’s listed, so pruning early doesn’t buy you anything and pruning late is safe. The command prints this reminder whenever a cookie prune is requested.

Rotating the pairwise salt

oidc.subject_identifiers.pairwise.salt (Hydra) is a scalar overwrite, not a rotation list: there’s no prune step, because there’s nothing to keep around. The salt derives every pairwise sub Hydra has ever issued per client; rotating it changes all of them, permanently, the moment the write is confirmed. Any downstream app that matches users by their pairwise sub will see what looks like a brand-new account for every user, forever. Hydra does not hot-reload, so the new salt only takes effect once Hydra restarts.

Because this is irreversible and blast-radius-wide, --yes does not satisfy the confirmation gate. Interactive mode requires typing a specific confirmation phrase verbatim; non-interactive mode requires --i-understand-subs-change. Before either, the command makes a best-effort call to Hydra’s admin API (when an admin URL is configured in hydra.yml) to report how many pairwise clients will be affected. This is informational only; it never blocks the rotation.

Backups and restore

Every write through forseti config’s mutating subcommands backs up the target file first, as <file>.bak.<unix-secs>, mode 0600, in a ring capped at the 3 most recent generations per file (older backups are pruned automatically). forseti config restore [--from <unix-secs>] lists what’s available per target (Kratos, Hydra, and config.toml when resolvable) and restores from a chosen generation: restoring is itself backed up first, so a restore is undoable too. Without --from, an interactive terminal is offered each target’s newest backup one at a time; non-interactively you must pass --from. A restore copies the backup’s bytes back verbatim (not re-serialized), so unlike every other config write it does not drop comments: restoring a hand-annotated file gives you the comments back exactly as they were.

config.toml/kratos.yml/hydra.yml are frequently git-tracked (the playground reference files are). Writes to kratos.yml, hydra.yml, and config.toml through the guarded write pipelines warn when the target is under git and remind you to gitignore the backups: add *.bak.* to .gitignore so a rotation doesn’t litter the repo with secret-bearing backup files. (config restore does not trigger this warning.)

--dry-run

Every mutating subcommand accepts --dry-run: it computes and prints the same redacted unified diff it would otherwise write, backs up nothing, writes nothing, and any interactive confirmation prompt is skipped (there’s nothing to confirm). Use it to preview a rotation or an OIDC enable/disable before committing to it, or in CI to confirm a scripted change would do what you expect.

Offline schema validation

forseti config check lints Forseti’s own recommendations, but it’s not a substitute for validating that a hand-edited or CLI-generated kratos.yml actually parses as valid Kratos config. Kratos ships its own schema validator; run it offline against the pinned image version (see infra/docker-compose.yml) without standing up the full stack:

podman run --rm -v <dir-containing-kratos.yml>:/etc/config/kratos oryd/kratos:v26.2.0 \
  validate config /etc/config/kratos/kratos.yml

(substitute docker if that’s your runtime). Single-file bind mounts don’t see atomic writes. forseti config’s writes are temp-file-plus-rename (so a crash mid-write never corrupts the target), which replaces the file’s inode. Docker/Podman bind-mounting a single file (-v ./kratos.yml:/etc/config/kratos/kratos.yml) binds to that specific inode at container-start time: a rename on the host is invisible to the container until it’s restarted. So a config CLI write can silently not take effect from the container’s point of view even though the file on the host disk is correct. Bind-mount the containing directory instead (as the playground docker-compose.yml does: ./kratos:/etc/config/kratos), which doesn’t have this problem, or restart the container after every config write if you must bind-mount a single file.

Kratos configuration

Forseti is method-agnostic infrastructure: it renders whatever nodes Kratos serves on each self-service flow. Which methods are available is an operator decision made in kratos.yml. The reference playground config is at infra/kratos/kratos.yml.

Methods

Each block under selfservice.methods.* toggles a method. Forseti renders nodes from any enabled method without further configuration.

password

Almost always enabled. Username/password (Kratos uses the identifier from the schema; typically email).

selfservice:
  methods:
    password:
      enabled: true

code

Passwordless email codes. Useful as a first-factor alternative to passwords and as the channel for recovery and verification flows. Recommended.

selfservice:
  methods:
    code:
      enabled: true
      config:
        lifespan: 15m

totp

Time-based one-time passwords (Google Authenticator, 1Password, etc.) as a second factor.

selfservice:
  methods:
    totp:
      enabled: true
      config:
        issuer: example.com

The issuer string shows up in the user’s authenticator app. Set to your brand or hostname.

lookup_secret

One-time recovery codes. Pair with totp so users have a fallback when they lose their authenticator.

selfservice:
  methods:
    lookup_secret:
      enabled: true

webauthn

Hardware security keys (YubiKey, FIDO2) as a second factor. Requires a traits.webauthn field in the identity schema.

selfservice:
  methods:
    webauthn:
      enabled: true
      config:
        rp:
          id: accounts.example.com
          display_name: Example Accounts
          origins:
            - https://accounts.example.com

The relying-party id must match the cookie-bearing domain. Origins must include every URL the WebAuthn ceremony can be initiated from.

passkey

Passwordless first-factor passkeys. Same identity-schema and RP-config requirements as webauthn.

selfservice:
  methods:
    passkey:
      enabled: true
      config:
        rp:
          id: accounts.example.com
          display_name: Example Accounts
          origins:
            - https://accounts.example.com

oidc

Upstream OIDC providers (Google, GitHub, Microsoft, Apple). Operators register one OAuth app per provider on the provider’s side; Forseti’s forseti config oidc enable writes the client credentials into kratos.yml and renders one “Sign in with X” button per configured provider. See Managing configuration with forseti config below: that’s the supported path for adding a provider; the manual YAML shape here is for reference (e.g. reading an existing kratos.yml) or for providers the CLI doesn’t cover yet.

${VAR} is not interpolated. Kratos does not expand ${VAR}-style environment references anywhere in kratos.yml: that’s a common assumption carried over from tools like Docker Compose or Helm, but Kratos’s own config loader has no such substitution step (confirmed against the upstream config loader; there’s no ${...} expansion pass on the parsed YAML). Every value, including client_id and client_secret, must be the literal string Kratos will use. forseti config oidc enable writes secrets in literal, plaintext form (redacted only in this CLI’s own diff output) for exactly this reason. If you want secrets sourced from the environment at deploy time rather than baked into the file, template kratos.yml through your deploy tooling (Helm, Terraform, a sops/envsubst pre-render step) before Kratos ever reads it. Kratos itself never does that substitution.

Worked example for Google, via the CLI:

  1. Go to https://console.cloud.google.com/apis/credentials and create an OAuth 2.0 Client ID.
  2. Authorized redirect URI: https://accounts.example.com/self-service/methods/oidc/callback/google. Substitute accounts.example.com for your Kratos public hostname: the path is fixed by Kratos.
  3. Capture the client ID and client secret.
  4. forseti config oidc enable google --client-id <id> --client-secret-env GOOGLE_CLIENT_SECRET (export the secret into that env var first, or use --client-secret-file/--client-secret-stdin; omit the flag entirely and the CLI prompts, masked, on a TTY). This writes the providers entry into kratos.yml and drops the reviewed mapper jsonnet next to it.

The resulting YAML looks like this (shown here so you know what to expect, or if you’re reading an existing config by hand):

selfservice:
  methods:
    oidc:
      enabled: true
      config:
        providers:
          - id: google
            provider: google
            client_id: 1234567890-abc.apps.googleusercontent.com
            client_secret: GOCSPX-actual-secret-value
            mapper_url: file:///etc/config/kratos/oidc.google.jsonnet
            scope: [openid, email, profile]

The mapper CLI-writes at oidc.google.jsonnet gates the email trait on claims.email_verified: copying email without that gate is an account-takeover vector (anyone who controls an unverified alias at the provider could claim the matching Forseti account). Don’t hand-edit the mapper unless you understand that invariant; forseti config check warns if a provider’s mapper doesn’t match Forseti’s reviewed pinned body.

Note the leading local claims = { email_verified: false } + std.extVar('claims'); on every pinned body. Kratos serializes the claim with omitempty, so an upstream false reaches the mapper as a missing field, and jsonnet raises Field does not exist: email_verified rather than treating it as false. Without the default, an unverified upstream address fails the sign-in outright instead of falling through to Forseti’s own verification flow.

Which providers’ verification Forseti trusts

Google’s and Apple’s mappers also emit identity.verified_addresses, which Kratos honours at identity-creation time: an address the provider marked verified is stored as an already-verified Kratos address, so the user never receives Forseti’s verification mail and is immediately eligible for verified-only features (org invites, verified-domain auto-join). GitHub’s and Microsoft’s mappers deliberately don’t, so their users verify through Forseti’s own flow.

The split is not about how strong each provider’s verification is, it’s about what the claim is bound to:

  • Googleemail_verified: true means Gmail (Google’s own namespace) or an address inside a Workspace domain Google verified with the domain owner. A Workspace admin can only assert addresses in domains their tenant owns, and Google enforces domain uniqueness across tenants.
  • Apple — Apple verifies the address at Apple ID creation, and operates the Hide My Email relay addresses itself. Carrying this over also avoids a dead end: relay addresses only accept mail from outbound domains you registered with Apple, so without the carry-over a Hide My Email user on a deployment that hasn’t done that registration can never verify at all.
  • GitHub — GitHub does verify addresses by confirmation link, and Kratos reads that flag straight off the account’s primary address. But GitHub isn’t an OIDC provider here (Kratos synthesizes email_verified from a REST field), nothing binds it to OIDC’s semantics for that claim, and an address released from one account can later be verified on another.
  • Microsoft — Entra ID issues no email_verified claim at all. Its equivalent is the xms_edov optional claim, which Kratos doesn’t read. Entra’s email is a mutable directory attribute a tenant admin can point at any address, which is why the pseudo-tenants are refused; even inside a pinned tenant, the claim carries no proof of mailbox control.

This is a deliberately conservative default rather than a ceiling. If your deployment federates only with a corporate IdP you have a contractual relationship with, carrying its verification over is defensible — but that’s an operator decision, made by hand-editing the mapper and accepting the config check warning, not something oidc enable will do for you.

Carry-over applies at identity creation only. It does nothing for identities that already exist, so upgrading doesn’t retroactively verify anyone, and it doesn’t affect account linking (Kratos rejects a social sign-in whose email collides with an existing identity, rather than merging).

Apple is the one provider that doesn’t fit that shape. Worked example:

  1. In the Apple Developer portal, enable Sign in with Apple on an App ID.
  2. Create a Services ID. That string (e.g. com.example.accounts.service) is the client_id — not the Team ID, not the Bundle ID.
  3. Configure the Services ID’s domain and return URL: https://accounts.example.com/self-service/methods/oidc/callback/apple. Apple rejects localhost and plain HTTP, so testing against a dev box needs a tunnel on a domain registered here.
  4. Create a Sign in with Apple key, download the .p8 (Apple lets you download it once), and note the Key ID and your Team ID.
  5. forseti config oidc enable apple --client-id com.example.accounts.service --apple-team-id ABCDE12345 --apple-key-id XYZ9876543 --apple-private-key-file ./AuthKey_XYZ9876543.p8
selfservice:
  methods:
    oidc:
      enabled: true
      config:
        providers:
          - id: apple
            provider: apple
            client_id: com.example.accounts.service
            mapper_url: file:///etc/config/kratos/oidc.apple.jsonnet
            scope: [email]
            apple_team_id: ABCDE12345
            apple_private_key_id: XYZ9876543
            apple_private_key: |-
              -----BEGIN PRIVATE KEY-----
              ...contents of the .p8...
              -----END PRIVATE KEY-----
            issuer_url: https://appleid.apple.com

No client_secret: Kratos signs one per handshake from those three fields. The provider id must stay apple — Apple replies with response_mode=form_post, and Kratos only exempts that callback path from CSRF for the apple id. Apple shares Google’s pinned mapper: the same email_verified gate, and the same carry-over of Apple’s verification into Kratos. Two behaviours worth telling your support desk about: users who pick “Hide My Email” arrive at a relay address (@privaterelay.appleid.com or @icloud.com — check the is_private_email claim rather than sniffing the domain) that breaks if they later revoke the app, and Apple only sends the name claim on the very first authorization. The email claim does arrive on every sign-in.

GitHub and Microsoft (Azure AD) follow the same forseti config oidc enable <github|microsoft> shape, with the gate but not the carry-over. GitHub only returns an email when the user:email scope is granted, and Kratos reads the verified flag off the account’s primary address rather than an id_token claim — an account whose primary is unverified arrives with no email at all and is asked for one during registration. Microsoft requires --microsoft-tenant <tenant-id>: common, organizations, and consumers are all refused outright, since each admits tenants you don’t control and opens the nOAuth account-takeover class where an attacker edits their own account’s email in a tenant Microsoft doesn’t verify. Note that Entra issues no email_verified claim in the first place, so Microsoft users always register their email through Forseti and verify it there.

Flow URLs

Forseti owns every UI surface; Kratos must point at it. Set every flow’s ui_url to the matching Forseti path.

selfservice:
  default_browser_return_url: https://accounts.example.com/
  allowed_return_urls:
    - https://accounts.example.com
    # add downstream apps if they rely on Kratos return_to:
    - https://app.example.com

  flows:
    login:
      ui_url: https://accounts.example.com/login
      lifespan: 10m

    registration:
      ui_url: https://accounts.example.com/registration
      lifespan: 10m
      after:
        password:
          hooks:
            - hook: session
            - hook: show_verification_ui

    recovery:
      enabled: true
      ui_url: https://accounts.example.com/recovery

    verification:
      enabled: true
      ui_url: https://accounts.example.com/verification
      after:
        default_browser_return_url: https://accounts.example.com/

    settings:
      ui_url: https://accounts.example.com/settings
      privileged_session_max_age: 15m

    error:
      ui_url: https://accounts.example.com/error

    logout:
      after:
        default_browser_return_url: https://accounts.example.com/login

Per-method post-settings landing

Kratos supports per-method selfservice.flows.settings.after.<method>.default_browser_return_url. Use these to land users back on the relevant sub-page after a save instead of sending them to a generic dashboard:

selfservice:
  flows:
    settings:
      after:
        password:
          default_browser_return_url: https://accounts.example.com/settings/password
        profile:
          default_browser_return_url: https://accounts.example.com/settings/profile
        totp:
          default_browser_return_url: https://accounts.example.com/settings/2fa
        lookup_secret:
          default_browser_return_url: https://accounts.example.com/settings/2fa
        webauthn:
          default_browser_return_url: https://accounts.example.com/settings/2fa
        passkey:
          default_browser_return_url: https://accounts.example.com/settings/2fa

CORS

Kratos’s public API serves CORS preflights when Forseti’s browser-side JS (HTMX) calls it. Forseti’s origin must appear in serve.public.cors.allowed_origins:

serve:
  public:
    cors:
      enabled: true
      allowed_origins:
        - https://accounts.example.com
      allowed_methods: [POST, GET, PUT, PATCH, DELETE]
      allowed_headers: [Authorization, Cookie, Content-Type]
      exposed_headers: [Content-Type, Set-Cookie]

Identity schema

The schema declares which traits an identity has (email, name, optional WebAuthn handles). Schemas are referenced by URL or file path. Place the schema file alongside kratos.yml:

identity:
  default_schema_id: default
  schemas:
    - id: default
      url: file:///etc/config/kratos/identity.schema.json

Forseti renders whatever fields the schema declares; adding traits.given_name to the schema causes the registration and settings/profile flows to gain a corresponding input.

Hydra configuration

Hydra is the OAuth2 server. Forseti is the IdP UI Hydra delegates to. Reference config: infra/hydra/hydra.yml.

URLs

urls:
  self:
    issuer: https://hydra.example.com
  login:   https://accounts.example.com/oauth/login
  consent: https://accounts.example.com/oauth/consent
  logout:  https://accounts.example.com/oauth/logout
  • issuer is the public hostname downstream apps see in iss claims and use for OIDC discovery.
  • login, consent, logout redirect the user to Forseti carrying a challenge query parameter. Forseti exchanges the challenge with Hydra’s admin API and accepts or rejects it.

Secrets

secrets:
  system:
    - <64-byte random string>

oidc:
  subject_identifiers:
    supported_types: [pairwise, public]
    pairwise:
      salt: <32-byte random string>

secrets.system encrypts everything in Hydra’s database (consent grants, refresh tokens). Rotate periodically; Hydra supports rolling rotation by appending the new secret as the first list element and keeping the previous one for decryption.

Client registration

Use the hydra CLI against the admin API. Example for a first-party app:

hydra create client \
  --endpoint http://hydra-admin.internal:4445 \
  --name "Example App" \
  --grant-type authorization_code,refresh_token \
  --response-type code \
  --scope "openid offline_access email profile" \
  --redirect-uri https://app.example.com/auth/callback \
  --token-endpoint-auth-method client_secret_post \
  --backchannel-logout-uri https://app.example.com/auth/backchannel-logout \
  --metadata '{"skip_consent": true}'
  • skip_consent: true in client metadata auto-grants consent without prompting. Set this only for clients the operator trusts to honor scope semantics (typically first-party apps).
  • Capture the printed client_id, client_secret, and registration_access_token and pass them to the downstream app’s operator.

See integration-guide.md for the downstream-app perspective on registration parameters.

Spec alignment (OAuth 2.1 / RFC 9700)

Where the playground sits relative to current OAuth / OIDC normative work (as of May 2026):

Spec / behaviourStatus in this stack
OAuth 2.1 draft-15 — PKCE on every code flow (S256)Enforced for public clients via Hydra oauth2.pkce.enforced_for_public_clients: true (infra/hydra/hydra.yml:70)
OAuth 2.1 — Implicit grant removedNot enabled on the playground; do not add response_type=token clients
OAuth 2.1 — ROPC removedNot enabled
OAuth 2.1 — Exact-string redirect matchingHydra default; no wildcard / prefix matching
OAuth 2.1 — Refresh tokens sender-constrained OR rotatedRotated (Hydra default; one-shot with reuse detection)
RFC 9068 JWT Access Token profile (typ=at+jwt)Partial — Hydra v26 emits JWT access tokens with typ: JWT. Strict RFC 9068 validators that require typ=at+jwt will reject. Either relax your validator or stay on opaque tokens + introspection until Hydra ships the profile
RFC 8707 Resource Indicators (resource= parameter)Hydra does not yet bind resource= into the access token’s aud — use Hydra’s audience= allow-list for that. Forseti does parse resource= off the original auth URL and records it as provenance on oauth_client_metadata.resource_url (src/oauth/consent.rs:412-437)
RFC 9449 DPoPNot implemented. Tokens are bearer-only
RFC 8705 mTLS client auth + cert-bound tokensNot configured
RFC 9126 PAR (Pushed Authorization Requests)Supported by Hydra; no Forseti-side enforcement
RFC 9101 JAR (signed request objects)Supported by Hydra; no Forseti-side enforcement
RFC 9396 RAR (Rich Authorization Requests)Not used
RFC 9700 OAuth Security BCP (Jan 2025)Reference document — the items above cover the BCP’s MUST-level requirements except DPoP/mTLS

MCP support

Hydra works as the authorization server for Model Context Protocol servers (Claude Desktop, Claude Code, claude.ai, ChatGPT). Forseti handles the UX side — the admin UI’s “MCP server” preset on /admin/clients/new pre-fills the right defaults (public client, PKCE, audience allow-list). This section is the operator-side checklist for the Hydra config that makes those clients work.

Required Hydra config

infra/hydra/hydra.yml for the playground shows the full shape. The MCP-relevant bits:

oauth2:
  pkce:
    # MCP MUSTs PKCE with S256 for every client (not just public). We
    # scope this to public — Hydra still requires PKCE whenever a client
    # has token_endpoint_auth_method=none, and confidential clients can
    # opt in per-client. Without this flag, a misconfigured client
    # (auth method `none`, no code_challenge) is silently weakened.
    enforced_for_public_clients: true

oidc:
  dynamic_client_registration:
    # Required for MCP — Claude Code refuses any AS that doesn't expose
    # `/oauth2/register` (RFC 7591), even when client_id is pre-configured.
    enabled: true
    default_scope:
      - openid
      - offline
      - offline_access

webfinger:
  oidc_discovery:
    # Surfaces `registration_endpoint` in /.well-known/openid-configuration.
    client_registration_url: https://accounts.example.com/oauth2/register

The playground ships with JWT access tokens and a 5-minute TTL, pinned in infra/hydra/hydra.yml:

strategies:
  access_token: jwt

ttl:
  access_token: 5m

Resource servers (MCP servers, downstream APIs) validate tokens locally against Hydra’s JWKS at https://hydra.example.com/.well-known/jwks.json — same key material as id_tokens, same verification shape. No admin-API reachability needed; no introspection round-trip on the hot path.

Why this is the default:

  • Resource servers can live anywhere. Serverless, third-party VPC, customer-managed infra — they need the public JWKS URL and nothing else.
  • Revocation lag is bounded to 5 minutes. The ttl.access_token: 5m cap is the whole point — once a user revokes a grant from /settings, the next refresh fails and the worst-case window before a stolen/revoked token stops working is the access-token TTL. Refresh tokens are revoked at /oauth2/token exchange time, which is the natural choke-point.
  • Refresh-token rotation is on by default (Hydra default). A replayed refresh token trips reuse detection and revokes the whole chain.

RFC 9068 conformance. Hydra v26 emits JWT access tokens with typ: JWT, not the typ: at+jwt that RFC 9068 requires. Strict RFC 9068 validators will reject. Options: (a) relax the validator on typ, (b) stay on opaque access tokens + introspection until Hydra ships the profile, (c) track Hydra’s RFC 9068 issue and switch when it lands. Mandatory claims (iss, exp, aud, sub, client_id, iat, jti) are all present in current Hydra output.

If you need true immediate revocation, switch to opaque tokens — but be clear about the tradeoff.

Token validation: opaque + introspection (alternative, private-network only)

Set strategies.access_token: opaque in hydra.yml to switch. The catch — and this is the tradeoff we want you to be completely clear-eyed about:

Opaque tokens require introspection on Hydra’s admin API (/admin/oauth2/introspect on :4445). The admin API is private. It MUST NOT be exposed to the public internet. Every resource server that needs to validate a token must have a route into your internal network to reach Hydra’s admin port.

This works fine when:

  • All your resource servers run on the same internal network as Hydra.
  • You operate a service mesh or private-link transport between RSes and Hydra.
  • You’re willing to stand up an authenticated introspection proxy (no such proxy ships with Forseti today — you’d build it).

This doesn’t work when:

  • Your MCP server runs on a third-party platform (Cloudflare Workers, Vercel, a customer’s VPC) without a route to your admin network.
  • You’re shipping the MCP server to integrators who can’t be expected to set up private connectivity.
  • You want third parties to validate tokens without granting them admin-network access.

If any of those apply, stay on the JWT default. The 5-minute revocation window is the price you pay for reachability, and for most use cases it’s the right trade.

If you do flip to opaque, the response shape from /admin/oauth2/introspect is RFC 7662 standard plus a custom ext field (whatever Forseti stuffed in at consent time). See src/oauth/consent.rs:build_id_token_claims for the contents.

Audience allow-list (Hydra’s non-standard audience parameter)

Hydra binds audiences at the auth-request level — clients pass audience=<url> on the authorization request, and Hydra issues a token with aud: ["<url>"]. The catch: values must be pre-registered on the client. Hydra does not yet implement RFC 8707 as of v26.2.0 (the current latest, March 2026), and emits no invalid_target error when a value isn’t registered — it silently drops the audience binding.

The admin UI’s MCP preset surfaces the audience textarea by default. Operators register their MCP server’s canonical URL (e.g. https://mcp.formshive.com) there; clients reference it on the auth request.

Track the upstream: ory/hydra RFC 8707 issues. When shipped, the current allow-list flow keeps working as a fallback — useful even after RFC 8707 lands, because real-world MCP clients (Claude.ai as of January 2026) don’t always send resource reliably.

Dynamic Client Registration (RFC 7591)

DCR is enabled because Claude Code refuses any authorization server that doesn’t advertise registration_endpoint in its discovery document, even when a client_id is pre-configured (anthropics/claude-code#38102). Hydra’s own /oauth2/register is fully anonymous once enabled: true — there is no Hydra-side token, allowlist, or CIDR gate.

Anonymous DCR is the default. Claude Code, Claude Desktop, and claude.ai have no way to present an Initial Access Token — they discover the registration endpoint from the OIDC document and POST to it directly. Locking DCR behind a mandatory bearer would make these clients unable to self-register, defeating the purpose of advertising the endpoint. Forseti therefore accepts anonymous registrations by default and relies on the verification badge + admin review as the safety mechanism: every DCR client lands as unverified, the consent screen renders a caution banner (“This application has not been reviewed by an administrator”), and end users see that banner every time until an operator reviews the client at /admin/clients?verification=unverified and explicitly promotes it via Mark as verified.

What Forseti still does (with or without an IAT):

  • Strips any metadata.forseti.* keys from the inbound body — defence against a caller trying to pre-seed trust state on the Hydra client.
  • Applies the reserved-name denylist (see below).
  • Applies the per-IP rate limit (oauth.dcr_ip_rate_per_minute / dcr_ip_rate_per_hour, see below).
  • Inserts a row into the Forseti-owned oauth_client_metadata table recording source = "dcr", verification = "unverified", and the registration timestamp. dcr_iat_id is set when an IAT was presented; NULL otherwise.
  • Audits the registration as oauth.client.dcr_registered.
  • Normalises Hydra’s response before returning it to the caller — empty-string URL fields (client_uri, policy_uri, tos_uri, logo_uri) and null array fields (contacts) are stripped so strict-parser clients (Claude Code, others) don’t reject a successful registration on Invalid URL / expected array, received null.

Discovery URL. Hydra is configured to advertise Forseti’s URL as the registration_endpoint:

webfinger:
  oidc_discovery:
    client_registration_url: https://accounts.example.com/oauth2/register

Hydra’s response (including its registration_access_token) is passed back to the caller verbatim — follow-up GET/PUT/DELETE /oauth2/register/{id} calls go straight to Hydra, since the registration access token Hydra issues is Hydra-validated.

Why oauth_client_metadata lives Forseti-side, not on the Hydra client’s metadata JSON: RFC 7592 PUT /oauth2/register/{id} (handled by Hydra, not Forseti) replaces the full client representation including metadata. If verification state lived on metadata.forseti.verification, a self-registered client could flip its own badge from "unverified" to "verified" via the RAT Hydra issues on registration. Moving the trust-boundary fields into a Forseti-owned table puts them out of reach of the RAT.

Optional Initial Access Tokens (IATs). IATs are an opt-in for operators who want to:

  • Pre-vouch a partner integration — issue an IAT to a known integrator so the resulting client lands attributable to a specific token (auditable via the dcr_iat:<id> actor on oauth.client.dcr_registered). Auto-promotion to Verified is not implemented yet; the operator still has to click Mark as verified.
  • Partition rate limits per tenant — the per-IAT daily counter (oauth.dcr_iat_daily_limit) is independent of the per-IP limit, so high-volume integrators can be carved out with their own quota.
  • Reject specific callers — when an IAT is revoked, registrations presenting it come back as 401 with the iat_exhausted audit reason.

/admin/dcr-tokens lists existing tokens and /admin/dcr-tokens/new mints fresh ones. Each token has:

  • A free-form note (visible only to operators).
  • An optional TTL in hours — blank = no expiry.
  • An optional max-use count — blank = unlimited. Single-use (1) is the safest default for IATs you hand to a specific integrator; Forseti decrements uses_remaining inside the same transaction as the lookup so two concurrent registrations with the same single-use token can’t both win.

The raw token (32 random bytes, base64url-encoded, no padding) is revealed exactly once on issue, via the same SecretReveal flash pattern as client secrets. Only sha256(token) is persisted — there is no way to recover a forgotten token; revoke and reissue.

A malformed Authorization header (wrong scheme, empty bearer value) is rejected with 401 + a dcr_rejected audit row, not silently treated as anonymous — that would let an attacker probe IATs without leaving a trail.

Auditing. Every successful registration emits oauth.client.dcr_registered with the returned client_id, posted client_name + scope, source IP hash, user agent, and a redirect-URI count (the full set is on the client itself). The actor is the IAT (dcr_iat:<id>) when one was presented, or system for anonymous registrations — the latter also carry anonymous: true in metadata. IAT lifecycle is auditable too: oauth.client.dcr_iat_issued and oauth.client.dcr_iat_revoked (the latter at critical severity so it surfaces in /admin/audit?severity=critical).

Surfacing self-registered clients. The /admin/clients list shows a “Self-registered” pill on rows whose Forseti-side oauth_client_metadata.source == "dcr", alongside the per-client verification badge described below.

Reviewing self-registered clients (Verified / Unverified). Every OAuth2 client carries a verification state in the Forseti-owned oauth_client_metadata table:

  • "verified" — green badge on the admin list + show page. The consent screen renders a subtle “Reviewed by your administrator” checkmark. Operator-created clients (anyone hitting New client on /admin/clients) are stamped verified at create time, since the act of an operator creating the client is the vouching. The verified_by and verified_at columns record who and when.
  • "unverified" — yellow/red badge in the admin UI. The consent screen renders a prominent caution banner: “This application has not been reviewed by an administrator. Only proceed if you trust it.” Self-registered DCR clients always start in this state. Forseti does not auto-promote — explicit admin action is required.

To review a self-registered client: open /admin/clients?verification=unverified, click into the client, eyeball the redirect URIs and client_name, and either:

  • Click Mark as verified — POSTs to /admin/clients/{id}/verify, sets verification = 'verified', verified_by, verified_at, and emits an oauth.client.verified audit row.
  • Click Delete if the client is illegitimate.

To revoke a previously granted verification (e.g. the client started behaving badly), click Revoke verification on the show page. POSTs to /admin/clients/{id}/unverify, flips Forseti row back to 'unverified', records verification_revoked_by / verification_revoked_at, and emits a critical-severity oauth.client.unverified audit row. The consent screen reverts to the caution banner on the next consent request.

Clients that exist on Hydra without a matching oauth_client_metadata row default to verified — those came in through the admin UI before this table shipped, so the implicit-trust rule applies retroactively. Verify or unverify lazily creates the row; no backfill needed.

Consent screen logo. The client show page carries a Consent screen logo card. Upload a PNG, JPEG or WebP (256 KB max) and it replaces the generic icon at the top of that client’s consent screen, so the user sees who they’re handing data to. The surrounding chrome stays operator- or org-branded, and /login is untouched: the sign-in page never varies per app, which is what keeps “my IdP login always looks the same” usable as a phishing check.

Whoever can administer the client can upload — Forseti admins for any client, org-scoped admins for clients in their own org. The image is stored in Forseti’s client_logos table and served from /clients/{client_id}/logo to signed-in callers only; anonymous requests get a 404 whether or not the client exists. The file type comes from the leading bytes, never the declared Content-Type or the filename, and SVG is rejected outright (it’s script-capable and would be served from your own origin). Uploads and removals emit oauth.client.logo_uploaded / oauth.client.logo_removed audit rows.

The client’s own logo_uri is deliberately not used here. It’s client-controlled through dynamic registration, and rendering a remote URL would make every user’s browser hit the relying party’s server from your consent page — leaking IP, user-agent and timing before the user has agreed to anything. Unverified clients still show their logo; the caution banner is the trust signal, and hiding the logo would only make “no logo” ambiguous between “not reviewed” and “never uploaded one”.

Reserved-name denylist. DCR registrations whose client_name matches any pattern in oauth.dcr_reserved_names are rejected with invalid_client_metadata (HTTP 400). The match is case-insensitive substring — "Microsoft Login", "forseti admin", and "AdminBot" all trip the default list. The default covers Forseti’s own brand, upstream Ory brands, common consumer IDPs (Google, Apple, Microsoft, GitHub, GitLab), AI vendors (Anthropic, Claude, OpenAI, ChatGPT), other identity vendors (Okta, Auth0), and obvious privilege names (admin, portal, system, root). Replace the list entirely in config.toml if you need different behaviour. The HTTP response intentionally doesn’t echo which pattern matched, so an attacker can’t enumerate the list by probing — but a rejected attempt is recorded in the audit log as oauth.client.dcr_rejected with reason = "reserved_name", the attempted name (truncated to 100 chars), the IAT id, and the source IP hash. The IAT use is not decremented when the name check fails, so an attacker can’t drain someone else’s single-use IAT by submitting reserved names.

Substring matching is too aggressive on short brand names. Because the match is case-insensitive substring rather than word-boundary, short entries like ory, hydra, kratos, claude, openai collide with legitimate client_name values that merely contain them. In real-world testing, Claude Code’s DCR was rejected because it sends client_name: "Claude Code (ory-demo)" — both claude and ory trip the default list. Word-boundary matching is a tracked follow-up; until it lands, operators running real Claude integrations should either remove the conflicting short strings from oauth.dcr_reserved_names (keeping the less collision-prone entries like microsoft, google, apple, github) or empty the list entirely if their threat model accepts it.

Workflow (default — anonymous DCR). No operator action required upfront. The MCP-client author calls:

curl -X POST https://accounts.example.com/oauth2/register \
  -H "Content-Type: application/json" \
  -d '{
    "client_name": "My MCP server",
    "redirect_uris": ["http://127.0.0.1:5000/cb"],
    "grant_types": ["authorization_code", "refresh_token"],
    "response_types": ["code"],
    "token_endpoint_auth_method": "none",
    "scope": "openid offline_access"
  }'

Hydra’s response carries client_id and registration_access_token; the author keeps both. The client now exists in Hydra and shows up on /admin/clients as Self-registered + Unverified. Operator opens /admin/clients?verification=unverified, eyeballs the redirect URIs, scopes, and client_name, then clicks Mark as verified (or Delete if it looks illegitimate). The consent banner clears on the next consent request.

Workflow (optional — pre-vouching via IAT). When you want to pre-attribute a registration to a known integrator: mint an IAT from /admin/dcr-tokens/new, hand the integrator the one-shot reveal, and they pass it on the call:

curl -X POST https://accounts.example.com/oauth2/register \
  -H "Authorization: Bearer <iat>" \
  -H "Content-Type: application/json" \
  -d '{ ... same body as above ... }'

The audit row for the registration is then keyed on dcr_iat:<id> instead of system, so an operator triaging suspicious activity can find every event back to that one issued token. The client still lands unverified — IAT presentation is not (yet) an auto-promotion signal.

Rate limiting. Three independent layers stack in front of POST /oauth2/register, all belt-and-suspenders to the IAT itself.

  • Per-IP (in-memory, per-process). Two buckets enforced in parallel: 10 requests/minute and 100 requests/hour. Limits are configurable via oauth.dcr_ip_rate_per_minute and oauth.dcr_ip_rate_per_hour in config.toml; set either to 0 to disable that bucket. By default the limiter keys on the TCP peer IP (proxy.trust_forwarded_for = false) — unforgeable, but behind a reverse proxy that means every caller shares a single bucket (key = proxy’s IP), so size the limits accordingly. To get per-real-client buckets, set proxy.trust_forwarded_for = true — the limiter then reads X-Forwarded-For (first hop), falling back to X-Real-IP, Forwarded, and the socket peer. Only flip this on when your reverse proxy strips client-sent forwarded-for headers before re-adding its own; otherwise a direct caller forges X-Forwarded-For and bypasses the bucket. The haproxy sketches in operator-guide-proxy.md show the correct http-request del-header X-Forwarded-For + set-header X-Forwarded-For %[src] pattern. A throttled request gets 429 Too Many Requests with Retry-After: <seconds> and an RFC 7591-shaped body (error: "temporarily_unavailable"). Per-IP hits are not audited — too noisy; a trace-level log line is emitted instead. State is in-memory only and does not cross-replicate; multi-instance deployments still get useful per-process gating but a determined attacker spread across replicas can exceed the nominal rate by Nx.
  • Global (in-memory, per-process). Two buckets shared by all callers: 40 requests/minute and 400 requests/hour, configurable via oauth.dcr_global_rate_per_minute and oauth.dcr_global_rate_per_hour (0 disables). This bounds total registration traffic even when distributed sources or a spoofed X-Forwarded-For defeat the per-IP buckets — the same pattern as /registration’s global limiter.
  • Per-IAT (DB-backed, persists across restarts). Cap on successful registrations per IAT over a rolling 24-hour window opened by the first successful use. Default 50, configurable via oauth.dcr_iat_daily_limit (set to 0 to disable). The window resets in-place when 24h have elapsed since daily_window_started_at. Only successful registrations count — failed lookups, reserved-name rejects, and Hydra rejections don’t burn the counter. Both the uses_remaining decrement and the daily-counter increment are gated by the UPDATE’s WHERE clause inside a single transaction (uses_remaining > 0 AND, when the window is live and daily_limit > 0, daily_use_count < daily_limit), so two concurrent successes at either boundary can’t both win — the second UPDATE matches zero rows and falls through to the rejection path. A per-IAT rejection emits an audit row at WARNING severity (oauth.client.dcr_rate_limited, target = the IAT id) and returns 429 with error: "temporarily_unavailable", error_description: "iat daily limit exceeded".

Follow-ups tracked but not yet implemented:

  • TTL sweep for unused DCR clients (a self-registered client that never sees a token request after N days is probably abandoned).

If you don’t want any DCR at all, set dynamic_client_registration.enabled: false in hydra.yml — but be aware Claude Code will refuse to talk to your AS.

RFC 9728 — Protected Resource Metadata

MCP servers advertise their authorization server via RFC 9728. The chain:

  1. Client hits MCP server unauthenticated → 401 with WWW-Authenticate: Bearer resource_metadata="<url>".
  2. Client fetches <url> (typically /.well-known/oauth-protected-resource on the MCP server’s origin) → JSON pointing at Hydra.
  3. Client follows authorization_servers[0] to /.well-known/openid-configuration on Hydra → standard OIDC discovery.

Forseti isn’t in this chain — it’s purely MCP-server-side. Sample resource-metadata document (the MCP-server author publishes this; operators just need to make sure Hydra’s issuer URL is reachable):

{
  "resource": "https://mcp.example.com",
  "authorization_servers": ["https://hydra.example.com"],
  "scopes_supported": ["app:tool:invoke"],
  "bearer_methods_supported": ["header"]
}

Workflow: registering an MCP client from the admin UI

  1. /admin/clients/new → click the MCP server card.
  2. Name the client (e.g. “Claude Desktop — formshive MCP”).
  3. The form is pre-filled: authorization_code + refresh_token, none auth method, PKCE on, audience textarea visible, redirect-URI hints showing the common Claude callbacks.
  4. Set the audience allow-list to the MCP server’s canonical URL (one per line).
  5. Paste the MCP client’s redirect URIs. For Claude Desktop / Code these are loopback URLs (http://127.0.0.1:PORT/cb); for claude.ai, Anthropic’s hosted callback (https://claude.ai/api/mcp/auth_callback).
  6. Define custom scopes — <app>:<resource>:<verb> convention. Add corresponding descriptions under [oauth.scope_descriptions] in config.toml so the consent screen reads naturally. The client show page surfaces a warning banner for scopes that don’t have a description.
  7. Submit. The next page shows a one-shot reveal of the registration access token (no client secret — public client).

Verifying the discovery document

After bringing the stack up, confirm Hydra’s discovery doc carries everything MCP clients read:

curl -s http://localhost:4444/.well-known/openid-configuration | jq '{
  issuer,
  registration_endpoint,
  code_challenge_methods_supported,
  grant_types_supported,
  token_endpoint_auth_methods_supported
}'

Expected:

  • registration_endpoint present (DCR enabled).
  • code_challenge_methods_supported includes "S256".
  • grant_types_supported includes authorization_code, refresh_token, client_credentials.
  • token_endpoint_auth_methods_supported includes "none", "client_secret_post", "client_secret_basic".

Hydra does not advertise resource_indicators_supported (no RFC 8707 yet). Spec-strict MCP clients haven’t been observed to reject Hydra for that omission — track it in case behaviour changes.

State parameter

Even with PKCE, the Ory MCP guide recommends MCP clients still send state. Belt-and-braces: PKCE prevents code-injection, state prevents CSRF on the redirect. Forseti doesn’t enforce this; it’s a client-side recommendation worth surfacing to MCP-server authors.

At-rest hashing for refresh tokens and introspection caches

If the MCP server caches introspection responses (defensible up to ~30s) or stores refresh tokens it received on behalf of the user, hash them at rest with SHA-256+ rather than storing raw values. Note this in the MCP-server’s own deployment docs — Hydra and Forseti don’t enforce it.

SMTP

Two SMTP transports operate independently and can both point at the same relay:

  • Kratos courier — verification codes, recovery codes, MFA enrolment notifications, and any Kratos-template-driven mail. Configured under courier.smtp in kratos.yml.
  • Forseti mailer — org invites and the hand-rolled /claim-email verification code. Configured under [email] in config.toml. Forseti sends directly (via polymail) because Kratos’s admin API doesn’t expose a one-off “send this message” endpoint in v26+.

Kratos courier

Kratos sends verification, recovery, and code emails through SMTP. Replace the playground Mailcrab config with a real provider:

courier:
  smtp:
    connection_uri: smtps://AKIAIOSFODNN7EXAMPLE:secret@email-smtp.us-east-1.amazonaws.com:465/?skip_ssl_verify=false
    from_address: no-reply@example.com
    from_name: Example Accounts

Worked example for Amazon SES:

  1. Verify the sender domain (example.com) in the SES console.
  2. Create an SMTP credential under “SMTP Settings”.
  3. Use the host email-smtp.<region>.amazonaws.com:465, SMTPS scheme, the credential as username/password.

For Postmark, substitute the connection URI: smtps://<server-token>:<server-token>@smtp.postmarkapp.com:465/.

Forseti mailer

Lives Forseti-side. Without it, invite + claim-email mails are dropped (the underlying token / code stays valid in the DB so an operator can hand-deliver in dev, but end users won’t see anything in their inbox). Pick a provider via provider; an SMTP relay (which can be the same one Kratos uses) looks like:

[email]
enabled      = true
from_address = "no-reply@example.com"
provider     = "smtp"
host         = "email-smtp.us-east-1.amazonaws.com"
port         = 465
tls          = "implicit"           # none | start_tls | implicit
user         = "AKIAIOSFODNN7EXAMPLE"
pass         = ""                    # set via FORSETI_EMAIL__PASS in prod

Or a transactional API provider (token injected via env):

[email]
enabled      = true
from_address = "no-reply@example.com"
provider     = "postmark"           # or lettermint
token        = ""                    # set via FORSETI_EMAIL__TOKEN

(SendGrid is the same shape but uses api_key instead of token, injected via FORSETI_EMAIL__API_KEY.)

Sanity-check: omitting the section (or enabled = false) leaves the mailer dormant — useful for OSS deployments that don’t have a provider handy or for tests. Disabled-state callers tracing::info! the would-be recipient and continue without error, so the surrounding flow still completes.

Email templates

Kratos ships default templates but they are plain. Override them per flow:

courier:
  template_override_path: /etc/config/kratos/email-templates

selfservice:
  flows:
    verification:
      notify_unknown_recipients: false
    recovery:
      notify_unknown_recipients: false

Place templates at /etc/config/kratos/email-templates/<flow>/<template>.gotmpl. See https://www.ory.sh/docs/kratos/concepts/email-templates.

Member profiles

Off by default. When [profiles].enabled = true:

  • /settings/profile grows a Public profile form (username, bio, location, pronouns, website, avatar URL, links).
  • /users/{identity_id} renders a profile view — only when the viewer shares at least one org with the target. Anonymous viewers and non-sharing viewers see a 404 (not 403; no “this page exists” leak).
  • The members roster on /settings/organization/members links each row with a non-empty profile to that view page.
  • Avatar: external avatar_url only — no upload pipeline. When unset, a deterministic SVG identicon (hash → 5-cell mirrored pattern) renders as fallback.
  • Audit: profile.updated event on each save, plus profile.username_changed (with the old and new handle) whenever the username field changes. No view-events.
[profiles]
enabled = true

OIDC exposure

The profile scope picks up additional standard OIDC claims when [profiles].enabled is on AND the user filled the fields:

  • picture — the avatar_url value
  • website — the website value
  • preferred_username — the handle the user chose, omitted when unset
  • updated_at — seconds since the epoch, last change to any portal-owned profile field

Usernames

The username field is what downstream apps read as preferred_username. Several of them provision a local account from it — Forgejo and Gitea derive the local username from this claim, and with ACCOUNT_LINKING = auto they will match an existing local account by it. That makes a recycled handle an account-takeover path, so Forseti is stricter than OIDC Core requires:

  • 2 to 39 characters, ASCII letters, digits, ., _, -, starting and ending alphanumeric. No @, so a handle can never be confused with an email address.
  • Unique case-insensitively across the deployment, enforced by a unique index rather than an application check.
  • Released handles are tombstoned and never reassigned; only the previous holder can reclaim one.
  • At most one change per 30 days per user.
  • A short denylist covers role words (admin, root, support, security, postmaster, …) and the vendor names already denied to self-registered clients.

Forseti never defaults the claim to the user’s email. Apps that want an email-derived local username should configure that on their own side (in Forgejo, [oauth2_client] USERNAME = email).

A new extended_profile scope exposes Forseti-specific claims:

  • bio (string, up to ~280 chars)
  • pronouns (string)
  • links (array of {label, url} pairs)

Add a description under [oauth.scope_descriptions] so the consent screen reads naturally:

[oauth.scope_descriptions]
extended_profile = "View your bio, pronouns, and personal links"

Revocation is whole-grant — to stop sharing the extended block, the user revokes the OAuth client at /settings/authorized-apps and re-consents with a narrower scope set.

When to leave it off

  • SaaS-shape deployments where customers share an org tenant but shouldn’t see each other’s profile data.
  • MCP / API-only deployments where users are mostly machines.
  • Anywhere bio + links would be noise rather than helpful context.

The OSS default is off so these deployments don’t accidentally surface a feature that doesn’t fit their topology.

Reverse proxy

The reverse proxy terminates TLS, forwards real client IPs, and routes to Forseti, Kratos public, and Hydra public.

Two topologies are supported:

  • Path-prefixed on one host (accounts.example.com/, /hydra/*, /kratos/*) — recommended. Same-origin everywhere, host-only cookies, no CORS to configure, only :443 exposed. Explicitly endorsed in Hydra’s production guide.
  • Subdomains (accounts.example.com, hydra.example.com, kratos.example.com) — workable, matches Ory’s canonical examples. Cross-origin once anything in Forseti calls Kratos/Hydra from the browser, so you’ll grow CORS config over time.

See operator-guide-proxy.md for the full reasoning, the third shape we evaluated and rejected (distinct ports), and haproxy configs for both supported shapes. The diagram at the top of this guide shows the subdomain shape; both are valid.

Nginx sketch (subdomain shape)

server {
    listen 443 ssl http2;
    server_name accounts.example.com;
    ssl_certificate     /etc/letsencrypt/live/accounts.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/accounts.example.com/privkey.pem;

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_set_header Host              $host;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-Host  $host;
    }
}

server {
    listen 443 ssl http2;
    server_name kratos.example.com;
    location / {
        proxy_pass http://127.0.0.1:4433;
        proxy_set_header Host              $host;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-Host  $host;
    }
}

# repeat for hydra.example.com -> 127.0.0.1:4444

Caddy sketch (subdomain shape)

accounts.example.com {
    reverse_proxy 127.0.0.1:3000
}

kratos.example.com {
    reverse_proxy 127.0.0.1:4433
}

hydra.example.com {
    reverse_proxy 127.0.0.1:4444
}

Caddy injects X-Forwarded-* headers automatically and handles TLS via Let’s Encrypt.

For the path-prefixed shape, the rewrite is the load-bearing bit — /hydra/* and /kratos/* must be stripped before the upstream sees them, since Hydra and Kratos don’t honour subpath mounting. The haproxy example in the proxy doc shows the exact rewrite rules.

Forseti currently logs the peer IP it sees from the TCP socket. With a proxy in front, that is the proxy’s IP. Forseti does not yet honor X-Forwarded-For for logging; treat the forwarded header as the source of truth in your log pipeline.

Secrets management

The following secrets must be unique, long, and protected:

SecretWhere it livesPurpose
hydra.yml: secrets.systemHydraEncrypts everything in Hydra’s database.
hydra.yml: oidc.subject_identifiers.pairwise.saltHydraPer-client pairwise subject identifier salt.
kratos.yml: secrets.cookieKratosSigns Kratos session cookies.
kratos.yml: secrets.cipherKratosEncrypts sensitive trait values at rest.
OIDC client secrets (per upstream)kratos.yml env substitutionAuthenticate to upstream OIDC providers.

Recommended pattern: load secrets from environment variables injected by your orchestrator or secrets manager (AWS Secrets Manager, HashiCorp Vault, Doppler, 1Password Connect). Do not commit any of the above to a repository.

Generate fresh values with openssl rand -base64 64 (cookie/session secrets) or openssl rand -hex 32 (cipher keys requiring 32 bytes).

Backups

  • Kratos’s Postgres database holds every identity, credential hash, and active session. Restoring it restores user accounts.
  • Hydra’s Postgres database holds OAuth2 client registrations, consent grants, refresh tokens, and the JWKS used to sign id_tokens. Losing the JWKS invalidates every previously-issued id_token’s signature.
  • Forseti’s own database holds organizations, the audit log, the webhook outbox, DCR tokens, and POSIX accounts. On the default sqlite backend that’s forseti.db next to the binary: copy it while Forseti is stopped, or use sqlite3 forseti.db ".backup backup.db" online (a plain file copy of a live WAL-mode database can be inconsistent). On Postgres, include it in the pg_dump routine.
  • Forseti’s webhook signing key ([webhook].signing_key_path, default data/webhook-signing-key.pem, created 0600) signs outbound Security Event Tokens. Without a backup, a rebuilt host mints a fresh key and kid, and receivers that pinned the old JWKS reject deliveries (see Rotating the webhook signing key).
  • Take daily logical backups (pg_dump) at minimum. Streaming replication or PITR is preferable for production.
  • Test restore quarterly. A backup you have never restored is not a backup.

Observability

Logs

  • Forseti emits JSON logs to stdout via tracing_subscriber. Levels: info (request lifecycle), warn (recoverable issues), error (handler failures). Forward to your aggregator (Loki, CloudWatch, Datadog).
  • Kratos and Hydra also emit structured logs; set log.format: json and log.level: info in their respective configs.
  • Set log.leak_sensitive_values: false in kratos.yml outside development.

Health endpoints

EndpointServicePurpose
/healthzForsetiLiveness. Returns ok if the process is up.
/readyzForsetiReadiness. Returns ready (200) when Forseti will serve. If the background webhook worker has been silent for more than 4x [webhook].tick_seconds (floor 20s), it still returns 200 but the body reads ready (degraded: webhook worker stale, ...); page serving is unaffected, so a stuck worker does not pull the instance out of rotation. Monitor the body (or logs) to catch a stale worker before undelivered webhooks pile up.
/health/aliveKratosLiveness.
/health/readyKratosReadiness (checks DB connectivity).
/health/aliveHydraLiveness.
/health/readyHydraReadiness (checks DB connectivity).

Wire all three readiness probes into your load balancer / orchestrator.

Metrics

Forseti exposes a Prometheus /metrics endpoint on the internal listener ([internal].bind) as a commercial feature: it needs a license with the observability capability and a configured scrape token, and it 404s otherwise. It serves HTTP RED metrics (request counts, latency, by method/route/status) plus a couple of bridged operational gauges. See Commercial: Observability for enabling it, what it exposes, and the scrape config.

Hydra and Kratos expose their own Prometheus metrics on their admin ports, independent of this.

Common gotchas

In the playground all services bind to 127.0.0.1 so cookies are port-agnostic and the browser sends Kratos’s session cookie back to Forseti at :3000 without further scoping. In production:

  • Kratos must serve from a hostname that shares a parent domain with Forseti. accounts.example.com (Forseti) and kratos.example.com (Kratos public) share .example.com, so Kratos can issue a cookie scoped to .example.com that both hostnames see.
  • Forseti still calls Kratos’s admin API on an internal hostname (e.g. kratos.internal:4434) for server-side operations. That call does not need cookie scoping.
  • The browser must reach Kratos’s public API on a publicly-resolvable hostname for cookie scoping to work. Path-rewriting Kratos behind Forseti’s hostname is possible but adds complexity; a separate hostname is simpler.

CORS

Kratos’s serve.public.cors.allowed_origins must include Forseti’s public URL. Without it, browser fetches to Kratos’s public API (used by HTMX during flow submission) fail silently or with a preflight error.

AAL2 auto-elevation after enrollment

When a user enrolls a second factor (TOTP, lookup_secret, WebAuthn, passkey) inside a privileged settings flow, Kratos automatically marks the session as aal2 going forward. The user does not have to re-authenticate to use the new factor. This is correct behavior but surprises operators verifying their setup — the second factor “just works” immediately because the enrollment ceremony itself satisfied AAL2. (Enforcement of AAL2 on subsequent logins is a separate concern — see Two-factor authentication enforcement.)

Settings flow per-method return URLs

Kratos’s selfservice.flows.settings.after.<method>.default_browser_return_url is consulted per method, not globally. Without per-method overrides, every settings save lands users on the same generic page. See the Flow URLs section.

allowed_return_urls

Kratos refuses to redirect to a return_to URL not in selfservice.allowed_return_urls. Add every downstream app hostname that drives a Kratos flow with ?return_to=.... Forseti’s own base URL must be in the list.

Issuer URL changes

If urls.self.issuer in hydra.yml ever changes, every previously-issued id_token becomes invalid (it embeds iss). Existing OAuth2 clients also discover endpoints via <issuer>/.well-known/openid-configuration, so OIDC discovery URLs change as well. Treat the issuer URL as immutable post-launch.

WebAuthn / passkey requirements

Forseti supports both WebAuthn (typically as a second factor) and passkeys (passwordless first-factor sign-in) via Kratos’s webauthn and passkey methods. End-user availability depends on browser + device support, which Forseti detects at page load:

  • WebAuthn buttons (“Sign in with hardware key”, “Add security key”) need any FIDO2 authenticator — a USB security key, a platform credential, a Bluetooth/NFC device, or a software emulator. Most modern browsers on most devices satisfy this.
  • Passkey buttons (“Sign in with passkey”, “Sign up with passkey”) need a platform credential specifically: Touch ID, Face ID, Windows Hello, an Android device passkey, or a synced passkey from iCloud Keychain / Google Password Manager / 1Password / Bitwarden / etc. Kratos’s passkey method hardcodes authenticatorAttachment: "platform" in the WebAuthn challenge to enforce this — cross-platform authenticators are explicitly rejected.

When Forseti detects a missing platform credential (PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable() returns false), it disables the passkey button and shows an inline explanation. WebAuthn buttons remain enabled because cross-platform authenticators are valid for them.

For local development, the most common gotcha is Linux + Firefox without TPM or a browser-side passkey store — passkey sign-in won’t work there. Workarounds:

  • Chrome DevTools virtual authenticator: F12 → “…” menu → More tools → WebAuthn → “Enable virtual authenticator environment” → Add authenticator (transport: internal, residentKey: true).
  • Firefox soft token: about:configsecurity.webauth.webauthn_enable_softtoken = true, restart.
  • Real device: macOS (Touch ID / Safari), Windows (Windows Hello / Edge), or Android (any modern browser).

Also note: WebAuthn requires either HTTPS or the origin to be localhost. Bare-IP origins like http://127.0.0.1:3000 are rejected by Firefox/LibreWolf as invalid RP IDs. The playground uses localhost deliberately for this reason. Production deployments must use HTTPS with a real domain.

Silent failures from Kratos’s helper

Kratos’s served webauthn.js swallows ceremony errors via .catch(err => console.error(err)), which means without intervention users see no feedback when a WebAuthn or passkey attempt fails. Forseti patches console.error at page load to forward DOMException-shaped errors into a visible banner above the form — see templates/partials/webauthn_helper.html. Operators forking the templates should preserve this helper.

Per-method registration hooks

selfservice.flows.registration.after is configured per credential method, not globally. If you enable a method (passkey, webauthn, code, oidc) but only configure hooks under after.password, users who sign up via the other methods complete registration but receive no session — they land on /login after signup with no clear indication they’re already registered. Symptom: the password signup path works fine but passkey/webauthn signup looks like “nothing happened.”

Add identical hook lists for every enabled method:

selfservice:
  flows:
    registration:
      after:
        password:    { hooks: [{ hook: session }] }
        passkey:     { hooks: [{ hook: session }] }
        webauthn:    { hooks: [{ hook: session }] }
        code:        { hooks: [{ hook: session }] }
        oidc:        { hooks: [{ hook: session }] }

The session hook auto-logs the new user in so they land on the dashboard rather than getting bounced to /login. Email verification is not enforced here by default — the dashboard’s verification banner prompts the user to verify at their leisure, and operators don’t gate features on the verified flag. This is the consumer-SaaS default (Notion, Linear, etc.).

If your product requires verified email before any dashboard access (typical for fintech, healthcare, B2B with PII), add { hook: show_verification_ui } after the session hook in each method. Kratos will then redirect to /verification after registration and only let the user proceed when the email is confirmed:

password:    { hooks: [{ hook: session }, { hook: show_verification_ui }] }

Mirror the playground config in infra/kratos/kratos.yml.

Commercial license

Forseti ships an offline-signed license gate under src/commercial/ that unlocks the paid-tier features outlined in ../MONETIZATION.md. The open-source build runs without a license and surfaces an upsell page on any gated capability — Organizations, SAML connectors, SCIM, SIEM streaming, bulk admin operations.

Activation

Paste the license blob you received from sales at /admin/license and click Activate. Forseti verifies the Ed25519 signature against the public key baked into the binary (src/commercial/pubkey.bin); no network call is made during activation. Verified licenses are persisted in the Forseti-owned forseti_license table as a singleton row and survive restarts.

Configuration

[license]
purchase_url = "https://example.com/buy"
  • purchase_url — where the upsell page’s CTA points. Empty default falls back to mailto:<brand.support_email>.

After expires_at, gated features stay read-only for a fixed 30 days before hard-gating. This grace window is not operator-configurable.

Revocation tradeoff

Licenses are offline-verified. Forseti never phones home, so once a blob is signed it can’t be revoked remotely. Two mitigations:

  • Yearly licenses self-expire. A leaked Pro or Enterprise blob is invalid within the renewal window, automatically.
  • Lifetime licenses are sold only on the Light tier, where the blast radius of a leak is bounded by the per-license org cap.

If you need true revocation (e.g. a customer churns with 9 months left on their Pro renewal), the operational answer today is to re-issue every outstanding license against a rotated keypair — the leaked blob fails signature verification on the next deploy. Plan key rotation as a customer-facing event, not a routine operation.

Pubkey rotation

To rotate the verification key:

  1. In the issuer repo (forseti-license), run ory-license keygen --force to generate a fresh keypair.
  2. Copy keys/public.bin into Forseti at src/commercial/pubkey.bin and rebuild.
  3. Re-issue every outstanding license against the new private key and ship the new blobs to customers.
  4. Roll out the new binary. Forseti logs license: persisted blob no longer verifies (likely pubkey rotated); operator must re-activate and falls back to Unlicensed for any unmigrated install.

No overlap window: an install on the new binary won’t accept blobs signed by the old key.

Organizations

Even OSS deployments carry a real organizations table (seeded with one “Default” row). Multi-org is a commercial feature gated on Feature::Orgs; the Default org is free.

Default-org admin

/settings/organization is the operator UI for renaming the Default org, swapping its logo, setting a support email, and managing members. The page replaces the old “edit config.toml to add admins” workflow — admin.allowed_emails still works (it’s the Forseti-wide allowlist, separate from per-org owner/member roles), but new admins land cleanly via Member promotion in the UI.

First-user bootstrap

The first identity to complete registration on a fresh install is auto-promoted to owner of the Default org. The threat model assumes the operator is the first to register on a freshly-deployed instance.

Identities whose email matches admin.allowed_emails are also auto-promoted to Default-org owner regardless of registration order, so Forseti admins always have governance in the Default org.

Per-org branding

An org owner sets branding on the org’s settings page (/settings/organization/branding), and those values override [brand] in config.toml for any request resolved into that org’s scope; unset fields fall back to [brand]. Branding covers:

  • Theme presetdefault, midnight, or cyberpunk, each with an auto-derived dark-mode variant.
  • Brand colours — primary, on-primary (foreground on the primary), and secondary, entered as hex; the derived dark-mode palette is contrast-checked.
  • Logo — either a logo_url (absolute HTTPS; private, loopback, and cloud-metadata addresses are rejected) or an uploaded image (PNG/JPEG/WebP, ≤256 KB, validated by magic bytes and served from Forseti at /branding/{slug}/logo).
  • Support email, and the public-login toggle that exposes the org’s landing page at /o/{slug}.

The active org’s theme white-labels the whole authenticated app, not just the login screen. The Default org is treated like any other org for this resolution — operators who want a single brand for everyone leave the Default org’s branding empty.

[orgs] configuration

KeyTypeDefaultDescription
active_org_cookie_ttl_secondsu642592000 (30d)Validity of the signed forseti_active_org switcher cookie.
invite_ttl_daysi647How long a minted org invite stays claimable.
reserved_namesstring[](code-baked set)Org-name denylist (create + rename), case-insensitive/confusable-folded substring match. When absent, falls back to the same built-in operator-brand denylist as oauth.dcr_reserved_names.
logo_ip_rate_per_minuteu3260Per-IP rate limit on GET /branding/{slug}/logo, requests per minute. 0 disables the bucket.
logo_ip_rate_per_houru32600Per-IP rate limit on GET /branding/{slug}/logo, requests per hour, in parallel with the per-minute bucket. 0 disables the bucket.
landing_ip_rate_per_minuteu3260Per-IP rate limit on GET /o/{slug} (the public landing page), requests per minute. 0 disables the bucket.
landing_ip_rate_per_houru32600Per-IP rate limit on GET /o/{slug}, requests per hour, in parallel with the per-minute bucket. 0 disables the bucket.
landing_global_rate_per_minuteu32300Global (all-callers-share-one-bucket) rate limit on GET /o/{slug}, requests per minute, shared across every slug. 0 disables the bucket.
landing_global_rate_per_houru323000Global rate limit on GET /o/{slug}, requests per hour, in parallel with the per-minute global bucket. 0 disables the bucket.
domain_verify_http_file_enabledbooltrueOffer the HTTP well-known-file domain-ownership method on the domains page. false disables it deployment-wide.
domain_verify_dns_txt_enabledbooltrueOffer the DNS TXT domain-ownership method.
domain_verify_email_enabledbooltrueOffer the email (admin@/postmaster@) domain-ownership method.
domain_verify_http_timeout_secondsu6410Total timeout for the HTTP well-known-file fetch.
domain_max_per_orgu32100Ceiling on registered domains (pending + verified) per org; bounds row growth and challenge-email fan-out.

External access mode (public self-serve)

A licensed, non-Default org can switch from internal (invite-only, the default) to external, which stands up a public landing page at /o/<slug> and a self-serve /join/confirm flow. Only an org owner with an active Orgs license can flip the switch (require_external_mode_writable); the Default org can never be external.

Admins-only directory, hard-enforced. Switching to external automatically sets the member-directory visibility to administrators-only and turns public login on. Unlike other visibility settings, administrators-only is not just a default for external orgs — it’s enforced: an owner cannot loosen it to a more open policy while the org stays external. The attempt is rejected with a 400 and recorded in the audit log (org.visibility_changed, warning severity, marked failed) so a misconfigured or coerced owner leaves a trail. Switching the org back to internal lifts the restriction.

No verification gate on join, by design. /join/confirm joins the visitor as a member immediately on explicit CSRF-confirmed consent — there’s no “verify your email first” step. This is deliberate: verification only gates placement that is derived from the email (domain auto-join, below). Public self-serve derives membership from an explicit action for a specific org, not from the email, so the email isn’t the credential and a verification gate would add nothing. If your threat model needs verified-first public onboarding, force it at the identity layer by adding a Kratos show_verification_ui hook to the registration flow, and keep the unverified-account reaper running as the backstop against unverified squatters.

trust_forwarded_for prerequisite. The rate limits on /o/{slug} and /registration are per-IP; they’re only meaningful when [proxy].trust_forwarded_for is true and your reverse proxy actually strips inbound X-Forwarded-For before re-adding its own (see the proxy guide). Behind a proxy that doesn’t strip it, a caller can forge the header and dodge the per-IP bucket entirely — the global bucket ([auth]/[orgs] *_global_rate_*) is the backstop either way.

Rate-limit posture and its limit. GET /o/{slug} and GET /registration both carry paired per-IP + global buckets (see [orgs] configuration and [auth] configuration above). The known gap: the actual registration POST goes straight from the browser to Kratos’s own public endpoint — Forseti never sees it — so Forseti’s /registration limit only bounds page renders, not submissions. Rate-limit Kratos’s own public API at the reverse-proxy layer if you need to bound the POST itself.

CAPTCHA: not implemented, by design. Forseti doesn’t own the registration POST (see above), so a server-enforced CAPTCHA would need a blocking Kratos before hook plus a new Forseti verify webhook plus a client-side widget plus org-conditional logic — a multi-system integration disproportionate to what this phase covers. A client-side-only widget with no server-side check would be a placebo, so none was built. If you need bot-resistant signup today, put a CAPTCHA-capable WAF or reverse-proxy rule in front of Kratos’s public registration endpoint.

Internal domain auto-join

The complement to external mode, for internal orgs: an owner registers email domains the org controls, and a user whose verified email matches an ownership-proven domain is offered a one-click prompt to join that org (as a member) — the workforce equivalent of “anyone with an @acme.com address can join the Acme org”. Managed at /settings/organization(s)/{slug}/domains; owner-only, licensed, non-Default, and internal-only (external orgs use the self-serve path above instead).

Opt-in and prompt-based, never silent. Domain auto-join only happens when the owner sets the org’s join policy to auto-join (the default is invite-only); an internal org with proven domains but the invite-only policy stays invite-only. Even with auto-join on, the user is prompted on their dashboard (“You have a verified <domain> address, join <Org>?”) and joins only on explicit confirmation. The proven domain replaces the admin invite as the authorization, but the join is still an explicit act.

Ownership must be proven — a domain is not honoured until the org demonstrates control via one of three methods, each individually disableable in [orgs] config:

  • HTTP well-known file (domain_verify_http_file_enabled) — Forseti fetches https://<domain>/.well-known/forseti-domain-verify and checks it contains the minted token. The fetch runs through the same SSRF guard as outbound webhooks (HTTPS-only, internal/loopback/link-local/IMDS addresses rejected, DNS-rebinding re-checked at connect, no redirects, size-capped, domain_verify_http_timeout_seconds timeout), so an owner cannot point a “domain” at an internal host.
  • DNS TXT (domain_verify_dns_txt_enabled) — a TXT record at _forseti-verify.<domain> must contain the token.
  • Email (domain_verify_email_enabled) — the token is mailed to admin@<domain> and postmaster@<domain>; the owner pastes it back. The confirmation is a constant-time compare, and the mail names the requesting org and actor so abuse of a paid account is attributable.

Guardrails. A domain can be verified under at most one org globally (a partial unique index, not just app logic), so no org can claim a domain another already owns or absorb its users. Freemail/public domains (gmail, outlook, proton, …) are rejected at add time. Eligibility is gated on the user’s specific verified address (never the raw trait email), re-checked at the moment they confirm the prompt — so an unverified ceo@victimcorp.com registration is never offered or joined, and the prompt appears only once the user has clicked their verification link. domain_max_per_org caps how many domains an org can register.

Prerequisite. Because the join requires a genuinely verified address, this feature only works if Kratos email verification is enabled and identities are not created pre-verified (the playground default). Social sign-ins through Google or Apple are the deliberate exception — their addresses arrive already verified (see Which providers’ verification Forseti trusts), so a Google Workspace user on a domain your org has proven is eligible for auto-join on first sign-in. If you’d rather every auto-join be backed by a mail round-trip Forseti performed itself, hand-edit those mappers to drop the verified_addresses block. Removing a domain stops future auto-join but does not remove members who already joined under it.

[identity] configuration

[identity]
unverified_ttl_days = 7
  • unverified_ttl_days — TTL applied by the unverified-prune CLI. Identities with at least one unverified verifiable address AND created_at < now - N days are deleted. Default 7. GitHub uses 30; we run more aggressive because a stuck unverified squatter blocks the legitimate owner. Operators with a slower onboarding flow can dial up.

Unverified-account reaper

forseti unverified-prune

Walks Kratos’s identity list and deletes any identity that’s both old enough and still unverified. Mirrors audit-prune — same exit code semantics (0 = success, 1 = failure), same [database].skip_migrations plumbing (no migrations needed at all for this CLI; it only touches Kratos).

Strongly recommended as a cron, not just a CLI you might forget to run. Example systemd timer + service:

# /etc/systemd/system/forseti-unverified-prune.timer
[Unit]
Description=Daily unverified-account reaper

[Timer]
OnCalendar=daily
Persistent=true

[Install]
WantedBy=timers.target

# /etc/systemd/system/forseti-unverified-prune.service
[Unit]
Description=Run forseti unverified-prune
After=network.target

[Service]
Type=oneshot
User=forseti
WorkingDirectory=/opt/forseti
ExecStart=/opt/forseti/forseti unverified-prune

The reaper, together with the per-invite verified-only check and the hand-rolled claim-email flow at /claim-email, closes the unverified-email-squatting gap left by Kratos’s default registration.

Re-claim flow safety rails

The claim-email flow lets the legitimate owner of an email reclaim it from an unverified squatter. Two safety rails the operator should understand:

  • Admin-allowlist refusal. If the squatter’s email is in admin.allowed_emails, the claim is refused (both at mint and at confirm). Without this, an attacker watching for fresh entries in the allowlist could race the operator: as soon as a new admin email lands but before it’s verified, an attacker could claim it and inherit Forseti-admin. The refusal logs WARN claim-email: refused — target email is in admin.allowed_emails with the email + target identity id, but externally returns the same generic banner as the not-found branch (no enumeration leak). When that warn fires, the right escape hatch is for the operator to delete the bogus identity via /admin/identities and let the legitimate owner register clean.
  • TOCTOU re-check at confirm. If the legitimate owner happens to walk through /verification between the moment the claim code is minted and the moment the claimer submits it, the confirm path refuses to delete (now-verified identities are off-limits). Avoids the case where a verified user gets wiped because a race-window claim was already in flight.

The claim destroys the squatter’s identity and redirects the claimer to a fresh /registration. The claimer does not inherit any state — they pick their own password, set their own traits, and get a new Kratos identity UUID. Email ownership proves only the right to delete + register-fresh; it does not transfer the existing account.

Commercial features

Some features are gated behind a commercial license — see commercial/ for the overview and licensing model. In particular, Enterprise SAML SSO (per-org /sso/{slug} login against a corporate IdP) is documented in commercial/saml.md, and the multi-org model in commercial/organizations.md.

Further reading

Proxy Layout

Shape sketches

Example (1) — single host, path-prefixed upstreams

accounts.example.com

Forseti
  /login
  /settings
  /...
  /.well-known/webhook-jwks.json

Hydra                    (iss = https://accounts.example.com/hydra)
  /hydra/.well-known/openid-configuration
  /hydra/.well-known/jwks.json
  /hydra/oauth2/auth
  /hydra/oauth2/token
  /hydra/oauth2/register
  /hydra/oauth2/sessions/logout
  /hydra/userinfo
  /hydra/...

Kratos                   (server-to-server from Forseti; browser hits webauthn.js)
  /kratos/.well-known/ory/webauthn.js
  /kratos/self-service/...
  /kratos/sessions/whoami
  /kratos/...

Proxy must rewrite /hydra/* and /kratos/* to the upstream root path — Hydra and Kratos do not honour subpath mounting (hydra#352, kratos#1152).

Example (2) — Forseti at root, Hydra/Kratos on subdomains

accounts.example.com           (Forseti)
  /login
  /settings
  /...
  /.well-known/webhook-jwks.json

hydra.accounts.example.com     (iss = https://hydra.accounts.example.com)
  /.well-known/openid-configuration
  /.well-known/jwks.json
  /oauth2/auth
  /oauth2/token
  /oauth2/register
  /oauth2/sessions/logout
  /userinfo
  /...

kratos.accounts.example.com    (server-to-server from Forseti; browser hits webauthn.js)
  /.well-known/ory/webauthn.js
  /self-service/...
  /sessions/whoami
  /...

No path rewrites. Each upstream serves at its own root. Wildcard TLS cert (*.accounts.example.com) covers all three names.

Example (3) — single host, distinct ports

accounts.example.com:443       (Forseti)
accounts.example.com:8443      (Hydra, iss = https://accounts.example.com:8443)
accounts.example.com:9443      (Kratos)

Same well-known paths as Example (2) on each port. One TLS cert reused across ports.


Feasibility & tradeoffs (per Ory docs)

TL;DR: ship Shape (1), reserve (2) for when you outgrow it, do not ship (3).

#TopologyFeasible per Ory docs?Cookie modeCORS modeVerdict
1Single host, path-prefixedYes — explicitly endorsed by Hydra’s prod guide (Kong strip_request_path=true + preserve_host=true)Same-origin. Cookies default to host-only on accounts.example.com. SameSite=Lax. No Domain= needed. CSRF “just works”Not required for browsers (everything is same-origin). Hydra allowed_cors_origins only needed for RP-side token/userinfo XHRProduction-recommended
2Forseti at root, Hydra/Kratos on subdomainsYes — matches the canonical accounts.example.com / oauth2.example.com examplesCross-subdomain. Top-level navigation flows still work with SameSite=Lax; avoid widening with cookies.domain unless you have a reasonRequired if Forseti ever calls Kratos/Hydra from the browser. Kratos cors.allowed_origins and Hydra global CORS must include https://accounts.example.comWorkable
3Single host, distinct portsTechnically works (Kratos docs: “HTTP Cookies ignore ports”) but no Ory example uses this shape and Hydra’s CSRF debug guide flags host/port inconsistencyCookies shared across ports — but browsers treat :443 and :8443 as different origins (origin = scheme + host + port), so XHR between them is cross-originSame-origin per spec only when scheme+host+port match — any browser-side call needs full CORS, defeating the pointDon’t ship

Shape (1) details

Feasibility. Hydra’s production guide explicitly endorses this:

“If you use the Mashape Kong API gateway, you can achieve this by setting strip_request_path=true and preserve_host=true. This ensures Hydra correctly computes consent challenge values.” — Hydra self-hosted production

Set:

# hydra
urls:
  self:
    issuer: https://accounts.example.com/hydra
    public: https://accounts.example.com/hydra
# kratos
serve:
  public:
    base_url: https://accounts.example.com/kratos

Forseti’s /.well-known/webhook-jwks.json does not collide with Hydra’s /.well-known/jwks.json — from the browser’s perspective Hydra’s is at /hydra/.well-known/jwks.json. Hydra’s published discovery doc will (correctly) advertise jwks_uri: https://accounts.example.com/hydra/.well-known/jwks.json because the issuer is set with the prefix.

Cookies & CSRF. Everything is same-origin. Three cookies coexist:

  • Forseti session/CSRF (Path=/, host-only, SameSite=Lax, Secure, HttpOnly)
  • Hydra ory_hydra_session, ory_hydra_login_csrf_<hash>, ory_hydra_consent_csrf_<hash> (host-only, Lax, Secure)
  • Kratos ory_kratos_session, csrf_token_<hash> (host-only, Lax, Secure)

Do not set cookies.domain on either Hydra or Kratos in this shape. Host-only is tighter and there’s no cross-subdomain traffic to enable.

Per the Hydra CSRF debug doc, path rewrites in proxies can interfere with cookie handling. Mitigation: haproxy doesn’t strip Cookie, and the path rewrite happens before the upstream sees the request, so cookie path matching works on both sides — browsers see Path=/ cookies (Hydra default) sent for any URL on the host.

CORS. Not required for any browser flow — login/consent are top-level navigations, Forseti calls Kratos/Hydra server-to-server, and /.well-known/ory/webauthn.js is a same-origin <script> load. allowed_cors_origins on individual OAuth2 clients is still needed for RPs that make browser-side token/userinfo XHR, but that’s RP-determined, not topology-determined.

Gotchas.

  • X-Forwarded-Proto: https is mandatory — without it, Hydra/Kratos emit http:// URLs and CSRF cookies without Secure. The CSRF debug doc is explicit about this.
  • X-Forwarded-Host must reflect the public hostname so issuer/return-to URLs stay consistent.
  • Path rewrite must be exact: /hydra/oauth2/auth → upstream /oauth2/auth. Off-by-one (/hydra/hydra/oauth2/auth) is the #1 source of “consent challenge invalid” errors.
  • No double-slashes after rewrite — some haproxy versions emit //oauth2/auth if you naively replace.
  • Admin APIs of Hydra (:4445) and Kratos (:4434) must not be exposed through the public proxy. Bind to loopback and either keep them off haproxy or expose on a separate internal listener.

Shape (2) details

Feasibility. This is the canonical Ory example shape (accounts.example.com for Kratos, oauth2.example.com for Hydra). Both deploy guides use it verbatim. Fully supported.

Cookies & CSRF. Three eTLD+1 siblings sharing accounts.example.com as parent. To make Forseti’s session cookie reachable when Hydra’s consent endpoint redirects back, two options:

  1. Host-only cookies, redirect-based flows. Each service sets its own host-only cookie. Cross-subdomain hops are top-level navigations, so SameSite=Lax allows the cookies on the GET that lands at each upstream. Right answer.
  2. cookies.domain=accounts.example.com on Hydra and Kratos. Per the Kratos multi-domain doc: “Subdomains can set HTTP Cookies for parent domains.” Don’t do this unless you have a concrete reason — widens scope unnecessarily and collides if Forseti cookie names overlap.

The Kratos multi-domain doc also notes:

“Setting up Ory Kratos in a way where you get session cookies running on two separate top level domains… is supported only on Ory Network or Ory Kratos Enterprise.”

Stay under one eTLD+1.

CORS. Now genuinely cross-origin if Forseti ever fetches Kratos from the browser. You don’t today (server-to-server), and /.well-known/ory/webauthn.js via <script> tag is not a CORS request — but any future browser-side whoami polling or JS-driven flow becomes one. Configure Kratos:

serve:
  public:
    cors:
      enabled: true
      allowed_origins: ["https://accounts.example.com"]
      allowed_methods: [POST, GET, PUT, PATCH, DELETE]
      allowed_headers: [Authorization, Cookie, Content-Type]
      exposed_headers: [Content-Type, Set-Cookie]
      allow_credentials: true

Hydra hard rule from its CORS doc:

“The authorization endpoint (/oauth2/auth) never supports CORS.”

Fine — it’s a navigation, not an XHR. Token/userinfo CORS is per-client via allowed_cors_origins plus global config for OPTIONS preflight.

Gotchas. Wildcard TLS cert or three SANs. Three DNS records. HSTS preload covers the parent — fine, but means no plaintext on any subdomain forever.

Shape (3) details

Feasibility. Kratos docs confirm cookies cross ports: “HTTP Cookies ignore ports.” So cookies flow across :443/:8443/:9443. But Hydra’s CSRF debug guide treats host/port inconsistency as a top failure mode, and no Ory example uses this shape.

Cookies & CSRF. Same-host, same-domain — cookies shared across ports. In practice:

  • Some browsers and proxies normalise :443 away but not :8443, leading to issuer mismatches in OIDC discovery.
  • Secure cookies on non-443 HTTPS ports work, but some corporate proxies / WAFs only understand 443.

CORS. Browsers treat https://accounts.example.com:443 and https://accounts.example.com:8443 as different origins (origin = scheme + host + port). XHR between them is cross-origin and needs full CORS — you get cookie-sharing of Shape (1) and CORS pain of Shape (2), with non-default ports that break corporate egress and look like a self-hosted toy.

Verdict. Don’t ship. No upside over (1).


haproxy sketches

Strip inbound X-Forwarded-* before setting your own. Forseti’s per-IP rate limiters (DCR proxy, handoff, claim-email) and the audit middleware default to keying on the TCP peer IP (proxy.trust_forwarded_for = false) — secure but, behind a proxy, every caller shares one bucket. To restore per-real-client buckets, operators set proxy.trust_forwarded_for = true and must guarantee the proxy strips client-sent X-Forwarded-* headers before re-adding its own; without the strip, a caller forges X-Forwarded-For: <random> and bypasses the limit (and spoofs their audited IP). The sketches below delete the inbound headers before set-header; operators using a different proxy (nginx, caddy, envoy) must do the equivalent before turning the flag on.

And keep the listener unreachable except through that proxy. Stripping at the proxy only helps for traffic that goes through the proxy. Forseti has no trusted-proxy allowlist: with trust_forwarded_for = true it takes the first X-Forwarded-For hop from whatever peer opened the connection. So the flag is only safe while Forseti’s own listener refuses direct connections — bind it to loopback or a private interface, or firewall the port to the proxy’s address. If the listener is directly reachable, a caller bypasses the proxy entirely and picks their own X-Forwarded-For, which sets both the client IP written to the audit log and the key for every per-IP rate-limit bucket; rotating the header value per request gives them an unlimited supply of fresh buckets, leaving only the global limits in the way. Leave trust_forwarded_for = false (the default) until both conditions hold.

Shape (1) — path-prefixed

frontend fe_accounts
    bind *:443 ssl crt /etc/haproxy/certs/accounts.example.com.pem alpn h2,http/1.1
    http-request redirect scheme https code 301 unless { ssl_fc }

    # Drop anything the client may have sent — only our values are trusted.
    http-request del-header X-Forwarded-For
    http-request del-header X-Forwarded-Proto
    http-request del-header X-Forwarded-Host

    # Forwarded headers — Hydra/Kratos rely on these to emit https URLs
    # and to compute consent challenges (preserve_host equivalent).
    http-request set-header X-Forwarded-Proto https
    http-request set-header X-Forwarded-Host  %[req.hdr(host)]
    http-request set-header X-Real-IP         %[src]
    http-request set-header X-Forwarded-For   %[src]

    acl is_hydra  path_beg /hydra/
    acl is_kratos path_beg /kratos/

    # Strip the prefix BEFORE upstream sees it. Hydra/Kratos serve at root.
    http-request replace-path ^/hydra/?(.*)  /\1 if is_hydra
    http-request replace-path ^/kratos/?(.*) /\1 if is_kratos

    use_backend be_hydra  if is_hydra
    use_backend be_kratos if is_kratos
    default_backend be_forseti

backend be_forseti
    server forseti 127.0.0.1:3000 check

# DO NOT add backends for Hydra :4445 or Kratos :4434 here.
# Admin APIs must stay on loopback. If you need remote access,
# expose them via a separate internal listener with its own auth.
backend be_hydra
    # Hydra public port; admin (4445) bound to loopback, never proxied.
    server hydra  127.0.0.1:4444 check

backend be_kratos
    # Kratos public port; admin (4434) bound to loopback, never proxied.
    server kratos 127.0.0.1:4433 check

Shape (2) — subdomain ACLs

frontend fe_accounts
    bind *:443 ssl crt /etc/haproxy/certs/accounts.example.com-wildcard.pem alpn h2,http/1.1
    http-request redirect scheme https code 301 unless { ssl_fc }

    # Drop anything the client may have sent — only our values are trusted.
    http-request del-header X-Forwarded-For
    http-request del-header X-Forwarded-Proto
    http-request del-header X-Forwarded-Host

    # Same forwarded headers — each upstream sees its own subdomain.
    http-request set-header X-Forwarded-Proto https
    http-request set-header X-Forwarded-Host  %[req.hdr(host)]
    http-request set-header X-Real-IP         %[src]

    acl host_hydra  req.hdr(host) -i hydra.accounts.example.com
    acl host_kratos req.hdr(host) -i kratos.accounts.example.com
    acl host_forseti req.hdr(host) -i accounts.example.com

    use_backend be_hydra  if host_hydra
    use_backend be_kratos if host_kratos
    use_backend be_forseti if host_forseti

backend be_forseti
    server forseti 127.0.0.1:3000 check

# DO NOT add backends for Hydra :4445 or Kratos :4434 here.
# Admin APIs must stay on loopback. If you need remote access,
# expose them via a separate internal listener with its own auth.
backend be_hydra
    server hydra  127.0.0.1:4444 check

backend be_kratos
    server kratos 127.0.0.1:4433 check

Shape (3) — port-based (illustrative only — do not ship)

frontend fe_forseti
    bind *:443 ssl crt /etc/haproxy/certs/accounts.example.com.pem
    http-request del-header X-Forwarded-For
    http-request del-header X-Forwarded-Proto
    http-request del-header X-Forwarded-Host
    http-request set-header X-Forwarded-Proto https
    http-request set-header X-Forwarded-Host  %[req.hdr(host)]
    http-request set-header X-Real-IP         %[src]
    default_backend be_forseti

frontend fe_hydra
    bind *:8443 ssl crt /etc/haproxy/certs/accounts.example.com.pem
    # Hydra issuer must include :8443 — every RP integration sees it.
    http-request del-header X-Forwarded-For
    http-request del-header X-Forwarded-Proto
    http-request del-header X-Forwarded-Host
    http-request set-header X-Forwarded-Proto https
    http-request set-header X-Forwarded-Host  %[req.hdr(host)]:8443
    http-request set-header X-Real-IP         %[src]
    default_backend be_hydra

frontend fe_kratos
    bind *:9443 ssl crt /etc/haproxy/certs/accounts.example.com.pem
    http-request del-header X-Forwarded-For
    http-request del-header X-Forwarded-Proto
    http-request del-header X-Forwarded-Host
    http-request set-header X-Forwarded-Proto https
    http-request set-header X-Forwarded-Host  %[req.hdr(host)]:9443
    http-request set-header X-Real-IP         %[src]
    default_backend be_kratos

# DO NOT add backends for Hydra :4445 or Kratos :4434 here.
# Admin APIs must stay on loopback. If you need remote access,
# expose them via a separate internal listener with its own auth.
backend be_forseti
    server forseti 127.0.0.1:3000 check
backend be_hydra
    server hydra  127.0.0.1:4444 check
backend be_kratos
    server kratos 127.0.0.1:4433 check

Forseti’s internal listener

Separate from the public frontends above, Forseti binds a second HTTP listener on [internal].bind (no default — operator-configured). It exists for one purpose today: receiving Kratos’s webhook events at POST /internal/audit/kratos, authenticated with a shared bearer ([audit].webhook_token).

Bind it to loopback when Kratos and Forseti share a host, or to a private interface (or pod-network address inside a container) when they don’t. Never expose this on a public interface, and never add it to the haproxy frontends above — the public proxy must not route to it. CSRF middleware is not applied on this listener; the bearer token is the trust boundary.

/healthz and /readyz stay on the public listener, so load balancers don’t need to know about the second port.


Recommendation

Ship Shape (1). It’s the only one that:

  • Keeps everything same-origin (no CORS to configure, no cookie-domain widening)
  • Uses host-only cookies (tightest scope)
  • Survives corporate networks (only 443 exposed)
  • Lets you serve https://accounts.example.com as the single URL users see — oauth2.accounts.example.com leaks implementation detail
  • Is explicitly endorsed in Hydra’s production guide

Migrate to (2) only when there’s a concrete need — different rate-limit tiers per service, independent WAF rules, splitting Hydra to its own cluster. Until then the extra DNS records and CORS config buy you nothing. Skip (3) entirely.


Content-Security-Policy

Forseti does not set a Content-Security-Policy itself. If you add one at the proxy, it must allow Forseti’s inline <head> scripts: a pre-paint theme resolver (reads the saved light/dark/system choice and sets the dark class before first paint) and a couple of input-handling helpers. Either permit inline scripts (script-src 'unsafe-inline') or, if you need a strict policy, inject a per-request nonce. The pre-paint theme script is the one inline script that cannot be moved to an external file without reintroducing a flash of the wrong theme on load.

Integration Guide

For application developers integrating with an forseti deployment (e.g. accounts.example.com) as an OIDC Provider for single sign-on.

For operators deploying forseti itself, see operator-guide.md. For project status, see ../README.md.

What this is

forseti is a self-service UI and OAuth2 bridge over Ory Kratos and Ory Hydra. From a downstream application’s perspective:

  • The OAuth2 / OIDC Provider is Hydra, reachable at the operator’s public Hydra URL (e.g. https://hydra.example.com or, more commonly, the same hostname as Forseti under /oauth2/* depending on the operator’s routing).
  • The login / consent UI is Forseti at https://accounts.example.com. Hydra delegates user interaction to it via /oauth/login, /oauth/consent, /oauth/logout.
  • Your app does not talk to Forseti directly. It talks to Hydra’s OAuth2 endpoints and OIDC discovery document.

Integration follows the standard OIDC authorization-code flow. Forseti is a normal OIDC Provider as far as your OAuth2 library is concerned.

Spec alignment (OAuth 2.1 / RFC 9700)

Where the playground sits relative to current OAuth / OIDC normative work (as of May 2026):

Spec / behaviourStatus in this stack
OAuth 2.1 draft-15 — PKCE on every code flow (S256)Enforced for public clients via Hydra oauth2.pkce.enforced_for_public_clients: true (infra/hydra/hydra.yml:70)
OAuth 2.1 — Implicit grant removedNot enabled on the playground; do not add response_type=token clients
OAuth 2.1 — ROPC removedNot enabled
OAuth 2.1 — Exact-string redirect matchingHydra default; no wildcard / prefix matching
OAuth 2.1 — Refresh tokens sender-constrained OR rotatedRotated (Hydra default; one-shot with reuse detection)
RFC 9068 JWT Access Token profile (typ=at+jwt)Partial — Hydra v26 emits JWT access tokens with typ: JWT. Strict RFC 9068 validators that require typ=at+jwt will reject. Either relax your validator or stay on opaque tokens + introspection until Hydra ships the profile
RFC 8707 Resource Indicators (resource= parameter)Hydra does not yet bind resource= into the access token’s aud — use Hydra’s audience= allow-list for that. Forseti does parse resource= off the original auth URL and records it as provenance on oauth_client_metadata.resource_url (src/oauth/consent.rs:412-437)
RFC 9449 DPoPNot implemented. Tokens are bearer-only
RFC 8705 mTLS client auth + cert-bound tokensNot configured
RFC 9126 PAR (Pushed Authorization Requests)Supported by Hydra; no Forseti-side enforcement
RFC 9101 JAR (signed request objects)Supported by Hydra; no Forseti-side enforcement
RFC 9396 RAR (Rich Authorization Requests)Not used
RFC 9700 OAuth Security BCP (Jan 2025)Reference document — the items above cover the BCP’s MUST-level requirements except DPoP/mTLS

If you’re building an MCP server, see also Protecting an MCP server — Forseti does not host RFC 9728 Protected Resource Metadata on your behalf.

Registering your app

Your app is an OAuth2 client of Hydra. Forseti operator registers it; you tell them what to register. Ask for a client with the following:

Required:

  • Name — human label, shown on consent screens and in the operator’s admin UI.
  • Grant types — typically authorization_code, plus refresh_token if you need offline access.
  • Response types — typically code.
  • Scopes — start with openid (required for OIDC); add email, profile, offline_access as you need them. See Scope reference.
  • Redirect URI(s) — every URL Hydra is allowed to send the user back to after authentication. Exact match, no wildcards.
  • Token-endpoint auth method — one of:
    • client_secret_post — credentials in the form body. Most common.
    • client_secret_basic — HTTP Basic header.
    • none — public client (SPAs, mobile). Pair with PKCE.

Optional:

  • Backchannel logout URI — server-to-server endpoint Hydra POSTs to when the user signs out elsewhere. See Logout.
  • Post-logout redirect URI(s) — where Hydra sends the user after RP-initiated logout.
  • skip_consent: true — first-party apps where the operator pre-authorizes consent. Forseti skips the consent screen entirely. (This is a top-level client field, not a metadata key.)
  • metadata.forseti.account_deletion_url — HTTPS webhook target Forseti POSTs to when one of your users self-deletes. See Account deletion webhooks.
  • Audience — restrict tokens to a specific resource server aud.
  • Custom scopes — app-specific permission grants beyond the standard OIDC set.

From the operator, capture:

  • client_id
  • client_secret (confidential clients only)
  • registration_access_token — lets you rotate the client config later without going back to the operator.

For the operator-side CLI invocation, see operator-guide.md.

The auth code flow

Use a library. Any conformant OIDC client (openid-client for Node, go-oidc for Go, authlib for Python, nimbus-jose-jwt/oauth2-oidc-sdk for Java) handles every step in this section — discovery, the redirect dance, code exchange, id_token validation, refresh. Point it at https://hydra.example.com/.well-known/openid-configuration and you’re done. The rest of this section is what’s happening under the hood — read it if you’re debugging, writing your own client, or just curious.

                         +-----------+
                         | Your App  |
                         |           |
                         | 1. User   |
                         |    clicks |
                         | "Sign in" |
                         +-----+-----+
                               |
                               | 2. 302 to /oauth2/auth?... on Hydra
                               v
                       +---------------+
                       |     Hydra     |
                       | (OAuth2 srvr) |
                       +-------+-------+
                               |
                               | 3. 302 to /oauth/login?login_challenge=...
                               v
                     +-------------------+
                     |    forseti     |
                     |  login + consent  |
                     +---------+---------+
                               |
                               | 4. user authenticates, Forseti accepts
                               |    challenges with Hydra admin
                               v
                       +---------------+
                       |     Hydra     |
                       +-------+-------+
                               |
                               | 5. 302 to https://yourapp.com/auth/callback?code=...&state=...
                               v
                         +-----------+
                         | Your App  |
                         |           |
                         | 6. POST   |
                         |    /token |
                         |    code   |
                         +-----+-----+
                               |
                               | 7. {access_token, id_token, refresh_token}
                               v
                         +-----------+
                         | Your App  |
                         | session   |
                         +-----------+

1. Start the flow

Redirect the user from your app to Hydra’s authorization endpoint:

https://hydra.example.com/oauth2/auth
  ?client_id=<your client_id>
  &response_type=code
  &scope=openid+email+profile+offline_access
  &redirect_uri=https://yourapp.com/auth/callback
  &state=<random, >= 8 chars>
  &nonce=<random, recommended>
  • state is mandatory and must be cryptographically random per attempt. Store it in your app’s pre-session (server-side or signed cookie) and compare on callback. Defends against CSRF on the redirect.
  • nonce is optional but recommended; binds the id_token to this specific flow. Store and compare on callback.
  • prompt=login forces re-authentication even if the user has an active Forseti session.
  • acr_values=aal2 requests a second-factor step-up (see AAL step-up).
  • max_age=<seconds> requires the user to have authenticated within the window; otherwise re-prompts.

2. Handle the callback

Hydra redirects the browser to redirect_uri:

https://yourapp.com/auth/callback?code=<authorization code>&state=<your state>

Verify state matches what your app sent. If it does not, abort with a 400.

3. Exchange the code for tokens

POST /oauth2/token HTTP/1.1
Host: hydra.example.com
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=<the code>
&redirect_uri=https://yourapp.com/auth/callback
&client_id=<your client_id>
&client_secret=<your client_secret>

Response:

{
  "access_token": "eyJhbGciOiJSUzI1NiIs...",
  "id_token": "eyJhbGciOiJSUzI1NiIs...",
  "refresh_token": "ory_rt_...",
  "expires_in": 300,
  "scope": "openid email profile offline_access",
  "token_type": "bearer"
}

4. Validate the id_token

See Validating the id_token below. After validation, create a local session in your app and redirect the user to their destination.

The id_token

The id_token is a JWT signed with RS256 by Hydra’s signing key. The claims depend on requested scopes.

Always present

ClaimTypeDescription
issstringIssuer. Matches urls.self.issuer from Hydra’s config.
audstring[]/stringAudience. Contains your client_id.
substringThe Kratos identity UUID. Stable for the life of the user account.
auth_timenumberUnix seconds when the user authenticated.
iatnumberUnix seconds when the token was issued.
expnumberUnix seconds when the token expires.
sidstringSession ID. Used to scope back-channel logouts to a specific session.
acrstringAuthenticator context class reference. Typically aal1 or aal2.
amrstring[]Authentication methods used. E.g. ["password"], ["password","totp"].
jtistringUnique token ID. Use for replay defence on backchannel logout tokens.
at_hashstringHash of the access_token (first half of SHA-256(at), base64url). Bind id_token to at.
noncestringEchoed from your auth request if you sent one.

With email scope

ClaimTypeDescription
emailstringThe user’s primary email address.
email_verifiedbooleanWhether the address is verified: either the user completed Forseti’s own verification flow, or they signed in with a provider whose verification the operator trusts (Google, Apple).

Membership and verification are not the same

Forseti will issue a token for a user whose email is not verified (email_verified: false), and that user can already be a member of an org: everyone belongs to at least the Default home org, and an external org’s public signup admits anyone without a verification step. So:

  • Never authorize on email without checking email_verified == true. An unverified email claim only says “the user typed this address”, not “the user controls it”.
  • email_verified: true means Forseti’s operator vouches for the address, not that Forseti mailed it. For social sign-ins it may originate upstream (Google, Apple), so the assurance is that provider’s, inherited. Treat it the way you’d treat any federated assertion: good enough to key a profile on, not a substitute for your own step-up if you’re guarding something sensitive. Use sub — never email — as the stable user identifier.
  • A membership claim is not identity proof. Do not treat an org claim — least of all org.id = "default" — as evidence of who the user is or that they belong to your organization in a trusted sense. Membership of the Default home org and of open external orgs is not gated on verification.
  • Membership that is email-gated (domain auto-join, invite acceptance) always requires a verified address, but you cannot tell from the token which door a user came through, so apply the two rules above uniformly.

With profile scope

ClaimTypeDescription
namestringFull name, if present in the identity schema.
given_namestringFirst name, if present.
family_namestringSurname, if present.
preferred_usernamestringThe handle the user chose under Settings → Profile. Omitted when they haven’t set one.
updated_atnumberSeconds since the epoch, last time the user’s portal profile changed.

These come from the identity’s traits.* fields as configured by the operator’s identity schema. Absent fields are omitted from the token.

preferred_username

Forseti omits this claim entirely when the user hasn’t chosen a handle. It never falls back to the email address: that would leak the address to apps that asked for profile but not email, and hand you an email-shaped value you’d be tempted to key accounts on. Plan for it to be absent.

Do not use it as an account identifier. OIDC Core §5.7 is explicit: sub and iss together are the only stable, unique identifier, and preferred_username, like email and name, “MUST NOT be used as unique identifiers for the End-User”. Key your local records on sub; treat this claim as a display string or a signup suggestion. Mapping accounts by username is the nOAuth bug class, and it bypasses every control on the IdP side.

Forseti applies restrictions the spec doesn’t require, because apps key on the value anyway:

  • 2 to 39 characters, ASCII letters, digits, ., _ and -, starting and ending alphanumeric. No @, so a handle can never be mistaken for an email address.
  • Unique, case-insensitively, across the deployment.
  • Never reassigned. A released handle is tombstoned and only its previous holder can reclaim it.
  • Changeable at most once every 30 days, with a profile.username_changed audit row recording the old and new value.

updated_at moves whenever any portal-owned profile field changes, so it is a drift signal rather than a username-change signal. Both claims are snapshotted into the consent session, so a change only reaches you at the next authorization — see Freshness cheatsheet.

With groups scope

ClaimTypeDescription
groupsstring[]Slugs of the teams the user belongs to in their active org. Empty array when the user has no teams. Always present when the scope is granted.
groups_truncatedbooleanPresent and true only when the user is in more than 200 teams and the list was capped.

groups is scoped to the user’s active org (the same org the org claim resolves to). It reflects state as of the user’s last authorization and is not re-resolved on a refresh-token grant. See the scope reference.

Example decoded payload

{
  "iss": "https://hydra.example.com",
  "aud": ["a1b2c3d4-e5f6-7890-abcd-ef0123456789"],
  "sub": "f8c9d0e1-2345-6789-abcd-ef0123456789",
  "iat": 1700000000,
  "exp": 1700003600,
  "auth_time": 1700000000,
  "sid": "0a1b2c3d-4e5f-6789-abcd-ef0123456789",
  "acr": "aal1",
  "amr": ["password"],
  "jti": "9f8e7d6c-5b4a-3210-fedc-ba9876543210",
  "at_hash": "wfgvdfP3qS6mPq3jeKxYHA",
  "email": "user@example.com",
  "email_verified": true,
  "name": "User Example"
}

Validating the id_token

Use a library. Every mainstream OIDC client does these eight steps correctly, including JWKS caching and kid rotation. Hand-rolling validation is how you get CVEs. The steps below exist so you know what your library is doing — and so you can spot it doing the wrong thing.

Validation steps:

  1. Fetch the JWKS from https://hydra.example.com/.well-known/jwks.json. Cache with a TTL (~24h). When you see a kid not in your cache, refetch immediately.
  2. Look up the public key by the token’s kid header. Verify the signature with the declared alg (RS256).
  3. Verify iss == https://hydra.example.com (exact match).
  4. Verify aud contains your client_id.
  5. Verify exp > now (allow a small clock skew, e.g. 60 seconds).
  6. Verify iat <= now + skew.
  7. If you sent a nonce, verify it matches.
  8. If you also received an access_token, verify at_hash matches: at_hash == base64url(SHA-256(access_token)[0:16]).

Rust (jsonwebtoken)

#![allow(unused)]
fn main() {
use jsonwebtoken::{decode, decode_header, Algorithm, DecodingKey, Validation};
use serde::Deserialize;

#[derive(Debug, Deserialize)]
struct Claims {
    iss: String,
    aud: Vec<String>,
    sub: String,
    exp: i64,
    iat: i64,
    nonce: Option<String>,
    email: Option<String>,
    email_verified: Option<bool>,
}

fn verify_id_token(id_token: &str, jwks: &Jwks, expected_nonce: &str) -> anyhow::Result<Claims> {
    let header = decode_header(id_token)?;
    let kid = header.kid.ok_or_else(|| anyhow::anyhow!("id_token missing kid"))?;
    let jwk = jwks.find(&kid).ok_or_else(|| anyhow::anyhow!("unknown kid"))?;
    let key = DecodingKey::from_rsa_components(&jwk.n, &jwk.e)?;

    let mut v = Validation::new(Algorithm::RS256);
    v.set_issuer(&["https://hydra.example.com"]);
    v.set_audience(&[std::env::var("OIDC_CLIENT_ID")?]);
    v.leeway = 60;

    let data = decode::<Claims>(id_token, &key, &v)?;
    if data.claims.nonce.as_deref() != Some(expected_nonce) {
        anyhow::bail!("nonce mismatch");
    }
    Ok(data.claims)
}
}

Other languages

  • Go: github.com/coreos/go-oidc/v3/oidc handles JWKS caching, discovery, and validation.
  • Python: authlib or python-jose. Use Authlib’s OAuth2Session for the full flow.
  • Node.js: openid-client (the maintained successor to node-openid-client).
  • Java: nimbus-jose-jwt plus oauth2-oidc-sdk.

All of these consume the OIDC discovery document at https://hydra.example.com/.well-known/openid-configuration. Prefer discovery over hardcoded endpoints; it surfaces JWKS URI, supported algorithms, and endpoint URLs.

Refresh tokens

Use a library. OIDC clients handle refresh-token rotation, retry on transient errors, and surface invalid_grant as a “re-authenticate” signal. The Hydra-specific quirks below are for when you need to reason about behavior — your library has already done the right thing in 95% of cases.

If your initial scope included offline_access, the token response includes refresh_token. Exchange it for fresh tokens:

POST /oauth2/token HTTP/1.1
Host: hydra.example.com
Content-Type: application/x-www-form-urlencoded

grant_type=refresh_token
&refresh_token=<the refresh token>
&client_id=<your client_id>
&client_secret=<your client_secret>

What you get back

The refresh_token field is an opaque string (ory_rt_...) — always opaque, regardless of whether access tokens are JWT or opaque. That’s deliberate: refresh tokens have to be immediately revocable, and JWT-style local validation can’t honor a revocation until the token expires. The format stays opaque even in the JWT access-token configuration.

The token response also does not include an expires_in for the refresh token itself. RFC 6749 doesn’t define one, and Hydra doesn’t surface a lifetime in the response — the only ways to know if a refresh token is still alive are (a) try to use it and handle invalid_grant, or (b) introspect it (see below). Hydra’s default refresh-token TTL is 720h (30 days); if your operator hasn’t tuned it, that’s what you’ve got.

Rotation behavior in the playground

The playground config (infra/hydra/hydra.yml) uses Hydra’s defaults: strict one-shot rotation. A refresh token can be redeemed exactly once — the successful response carries a new refresh_token, and the old one is dead the instant it lands at the token endpoint. Always overwrite your stored value.

Reuse — replaying a token Hydra has already seen — is a security signal. Hydra invalidates the entire token chain (current refresh token + every access token issued from it) and returns invalid_grant. The next call has to be a fresh auth flow.

Hydra also offers a graceful rotation mode (oauth2.grant.refresh_token.grace_period, off by default) that keeps the old token usable for a short overlap window. Useful if your app makes concurrent refresh attempts from multiple processes or tabs — without it, the second-place process gets invalid_grant on a token the first process already burned. Ask your operator to enable it if you’re hitting that race; the cost is a slightly larger reuse-detection blind spot.

Handling invalid_grant

A 400 with error: invalid_grant means the refresh token has been used, revoked, expired, or never existed. The default response is to force re-authentication.

One nuance with strict rotation: a network retry on a refresh request Hydra already processed lands as invalid_grant even though the user’s grant is fine — your retry looks like a replay. If you implement retries on transient errors, a single backoff-then-retry is defensible, but treat the second invalid_grant as authoritative and re-auth. Don’t loop.

Operators can also wire oauth2.refresh_token_hook to deny refresh based on out-of-band signals (account flagged, device revoked, step-up required). To the client, that surfaces as the same invalid_grant. Same handling: re-auth.

Checking validity without consuming the token

If you have a route into the operator’s admin network — service mesh, private link, anything reaching Hydra’s admin port — you can introspect a refresh token to check it’s still valid without redeeming it:

POST /admin/oauth2/introspect HTTP/1.1
Host: hydra-admin.internal:4445
Content-Type: application/x-www-form-urlencoded

token=<the refresh token>
&token_type_hint=refresh_token

Same caveat as the opaque access-token case (see Alternative: opaque + introspection): the admin API is private. If your app runs on Cloudflare Workers, Vercel, or anywhere without a tunnel into the operator’s network, introspection is not an option — you check validity by trying to refresh and handling invalid_grant.

active: true confirms the token is currently redeemable. active: false means dead — skip the redemption round trip and go straight to re-auth.

Refresh cadence

Refresh ahead of the access token’s expires_in. A common pattern is to refresh at 80% of the lifetime (~4 minutes in for a 5-minute access token). Don’t refresh on every request — that defeats the JWT-local-validation win and turns Hydra’s token endpoint into a hot path.

Revoking a refresh token

For explicit logout — or when a user disconnects an integration on your side — revoke the token rather than just dropping it:

POST /oauth2/revoke HTTP/1.1
Host: hydra.example.com
Content-Type: application/x-www-form-urlencoded
Authorization: Basic <base64(client_id:client_secret)>

token=<the refresh token>

RFC 7009. Revoking a refresh token kills it and every access token minted from it. Confidential clients authenticate on this endpoint; public clients pass client_id in the body without credentials. Always best-effort — the spec says return 200 even if the token was already invalid, so don’t trust the response code for diagnostics.

Patterns by client type

Confidential clients (server-rendered web apps, backend services). Store the refresh token in your session store, encrypted at rest. Hand client credentials to every refresh call. Handle invalid_grant by clearing the session and 302’ing the user to /oauth2/auth. No special storage gymnastics — your server is the trust boundary.

Browser SPAs. Don’t store refresh tokens in the browser. The modern recommendation (codified in draft-ietf-oauth-browser-based-apps) is the Backend-for-Frontend (BFF) pattern: a thin server colocated with your SPA holds the refresh token, exchanges it for fresh access tokens on the SPA’s behalf, and proxies API calls. The SPA holds nothing more than a session cookie scoped to the BFF. LocalStorage / SessionStorage / IndexedDB are all XSS-reachable; treat them as unsafe for any token that outlives a tab.

Native mobile apps. Refresh tokens live in the OS-provided secure store — Keychain on iOS (kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly or stricter), EncryptedSharedPreferences or Keystore on Android. Use PKCE, rotate on every refresh, and treat reuse-detection invalidation as a hard signal: your token was lifted from the device.

MCP clients. See Protecting an MCP server for the full picture. Short version: same rotation rules apply, store the refresh token in an OS-appropriate keychain (or a passphrase-encrypted file on Linux), and request offline_access if you want the agent to keep working past the 5-minute access-token TTL.

Reading user info

Two questions get conflated. The answers diverge, so they’re worth separating up front:

  1. What did the user look like when they signed in? — anything in your id_token stays valid for the life of the grant. Decode it locally, or call /userinfo. Both return the same thing.
  2. What does the user look like right now? — there’s no live “read identity” endpoint exposed to relying parties. You re-run the auth flow. See Getting fresh user info.

The reason is structural. User info lives in two places:

  • Kratos owns the identity record — email, email_verified, and whatever traits the operator’s identity schema declares (typically name.first, name.last).
  • Forseti’s own database owns everything else surfaced as claims — the extended profile (avatar, website, bio, pronouns, links) and org memberships.

Hydra owns none of it. At consent time, Forseti reads from both stores, folds the result into a claims object, and hands it to Hydra as the consent session payload (src/oauth/consent.rs, build_id_token_claims). Hydra caches that snapshot alongside the grant and replays it for every id_token and /userinfo call until the user consents again. The snapshot doesn’t update when the user edits their profile in Forseti — that’s the whole crux of the freshness question.

The data is already in two places by the time your app holds tokens: in the id_token you got at login, and behind Hydra’s /userinfo endpoint. Pick based on what token you hold:

You holdUseWhy
id_token (JWT)Decode it locally.Already signed, already validated, no network call.
JWT access token (default)Decode it. Same JWKS as the id_token.Same trust path; /userinfo is redundant.
Opaque access token (operator opt-in)GET /userinfo with the bearer token.The token is opaque to you — /userinfo is the read-out.

/userinfo shape:

GET /userinfo HTTP/1.1
Host: hydra.example.com
Authorization: Bearer <access_token>

Response is a JSON document with the same scope-gated claims as the id_token (sub, email, email_verified, name, picture, org, groups, …). Discovery surfaces the endpoint as userinfo_endpoint; libraries find it automatically.

No freshness difference between the two paths. They’re both the consent-time snapshot.

Getting fresh user info

If the user updated their email, name, or profile in Forseti after consenting, none of the snapshot paths see it. You trigger a new consent acceptance — that’s what re-reads Kratos and Forseti DB.

Three patterns, ranked by user-visible friction:

  1. Silent re-auth (SSO). Redirect to /oauth2/auth again, no prompt=login. If the user still has a live Forseti session — which they almost always do for the lifetime of the cookie — Hydra round-trips through Forseti silently and hands you a freshly-built id_token. With skip_consent: true on your client (first-party apps), the user sees nothing but a brief redirect chain. This is the right pattern when you want fresh claims on a specific page load (account settings, billing, anything that displays the user’s own info back to them).
  2. prompt=login. Forces a full re-auth (password, 2FA if enrolled). Use when you actually want the user to reauthenticate for a sensitive operation, not when you just want fresh claims — the friction is significant.
  3. Refresh-token exchange. Does not refresh claims in the playground config. Hydra reuses the cached consent session payload, so the new id_token carries the same email_verified / name / picture as the old one, with only iat / exp advanced. Hydra exposes an oauth2.refresh_token_hook that lets Forseti repopulate claims on every refresh, but infra/hydra/hydra.yml doesn’t wire it. If your operator has configured one (ask them), refresh becomes the cleanest path; otherwise treat refresh as token-lifetime extension only.

Freshness cheatsheet

Token / callWhat you getReflects Forseti edits?
id_token (held)Claims from when the user consented.No.
/userinfo callClaims from when the user consented.No.
Refresh-token grantNew id_token; same claims as the old one.No (unless operator wired a refresh hook).
Silent re-auth (SSO)New id_token built from a fresh Kratos + Forseti read.Yes.
prompt=loginSame, but interactive.Yes.

What about push notifications on profile change?

Nothing exists today. Forseti only pushes RISC account-purged (see Account deletion webhooks) — not profile.updated or email.verified. If your app needs near-real-time sync, poll /userinfo on a cadence that matters to you, or run a silent re-auth whenever the user lands on a page that displays their own info.

Logout

Two patterns. Use one or both.

Local logout

Destroy your app’s local session. Do nothing with the IdP. The user remains signed in at accounts.example.com and can return to your app via SSO without re-authenticating.

Appropriate when the user is logging out of your app specifically and you do not want to terminate their other federated sessions.

Global logout via RP-initiated logout

Redirect the user to Hydra’s end-session endpoint:

https://hydra.example.com/oauth2/sessions/logout
  ?id_token_hint=<the id_token from login>
  &post_logout_redirect_uri=https://yourapp.com/
  &state=<random>

Hydra forwards the user to Forseti’s /oauth/logout, which destroys the Kratos session, then redirects back to your post_logout_redirect_uri. The URL must be registered on the client at creation time (see Registering your app).

Global logout via back-channel

When the user signs out at accounts.example.com (or any other RP triggers a global logout), Hydra POSTs a logout token to every registered backchannel_logout_uri for that user’s active sessions.

Request shape:

POST /auth/backchannel-logout HTTP/1.1
Host: yourapp.com
Content-Type: application/x-www-form-urlencoded

logout_token=eyJhbGciOiJSUzI1NiIs...

The logout_token is a JWT signed by Hydra. Required claims (per OpenID Connect Back-Channel Logout 1.0):

ClaimRequiredNotes
issyesMust match Hydra’s issuer URL.
audyesMust contain your client_id.
iatyesReject if older than your accepted window (60s is typical).
jtiyesUnique token ID. Deduplicate against a short-lived cache to prevent replay.
eventsyesMust contain the key http://schemas.openid.net/event/backchannel-logout mapping to an empty JSON object.
subone ofThe user’s stable subject ID.
sidone ofThe specific session ID. At least one of sub/sid must be present.

Critically, the logout token must NOT contain a nonce claim — the spec forbids it.

Library support is patchy here. openid-client (Node) has first-class back-channel logout support; go-oidc exposes the primitives but you wire the handler yourself; authlib has helpers; many smaller libraries don’t cover it at all. If yours doesn’t, the validation below is what you need to implement.

Validation pseudocode (Rust shape):

#![allow(unused)]
fn main() {
async fn handle_backchannel_logout(form: Form<LogoutForm>) -> Response {
    let token = &form.logout_token;

    // 1. Decode header, find kid, fetch JWKS from Hydra, verify signature (RS256).
    let claims = match verify_logout_token_signature(token).await {
        Ok(c) => c,
        Err(_) => return StatusCode::BAD_REQUEST.into_response(),
    };

    // 2. Validate claims.
    if claims.iss != "https://hydra.example.com" { return StatusCode::BAD_REQUEST.into_response(); }
    if !claims.aud.contains(&client_id()) { return StatusCode::BAD_REQUEST.into_response(); }
    if (now() - claims.iat).abs() > 60 { return StatusCode::BAD_REQUEST.into_response(); }
    if !claims.events.contains_key("http://schemas.openid.net/event/backchannel-logout") {
        return StatusCode::BAD_REQUEST.into_response();
    }
    if claims.sub.is_none() && claims.sid.is_none() {
        return StatusCode::BAD_REQUEST.into_response();
    }
    if claims.nonce.is_some() { return StatusCode::BAD_REQUEST.into_response(); }

    // 3. Replay defence.
    if seen_jti(&claims.jti).await {
        return StatusCode::BAD_REQUEST.into_response();
    }
    remember_jti(&claims.jti, Duration::from_secs(600)).await;

    // 4. Destroy matching local sessions.
    if let Some(sid) = claims.sid {
        destroy_sessions_by_sid(&sid).await;
    } else if let Some(sub) = claims.sub {
        destroy_sessions_by_sub(&sub).await;
    }

    // 5. Respond.
    (StatusCode::OK, [(header::CACHE_CONTROL, "no-store")]).into_response()
}
}

Implementation notes:

  • The handler is invoked server-to-server, with no user cookies. Do not depend on session state in this endpoint.
  • Prefer sid over sub when present. A user can be signed in from multiple devices; sid lets you destroy one without affecting the others.
  • Return 200 on success, 400 on any validation failure. The spec discourages 5xx for validation errors.
  • Cache JWKS but be prepared to refetch on kid miss; Hydra rotates signing keys.

See the README’s Logout integration section for the original short-form summary.

AAL step-up

When your app exposes operations that warrant a second factor (delete account, change billing, manage API keys), force a step-up at the OIDC layer:

https://hydra.example.com/oauth2/auth
  ?client_id=<your client_id>
  &response_type=code
  &scope=openid+email
  &redirect_uri=https://yourapp.com/auth/callback
  &state=<random>
  &acr_values=aal2

Forseti observes the acr_values request, looks at the user’s current Kratos session, and:

  • If the session is already aal2, accepts immediately.
  • If the session is aal1 and the user has a second factor enrolled, prompts for the second factor before accepting.
  • If the user has no second factor, returns an error (or, depending on the operator’s acr_values strictness setting, accepts at aal1 — check the returned id_token’s acr claim).

Your app must inspect the returned id_token’s acr claim and reject aal1 for the sensitive operation. Do not assume acr_values=aal2 was honored; verify.

Note on enrolled users. When the operator enforces 2FA (the recommended config — session.whoami.required_aal: highest_available in Kratos), any user who has a second factor enrolled is forced through the step-up on every login through Forseti, even when your app didn’t send acr_values=aal2. So for those users the returned acr is aal2 regardless. You can’t rely on the absence of a step-up to mean a user has no second factor — only that they hadn’t enrolled one, or the operator hasn’t enabled enforcement. Still verify the acr claim when you need AAL2; don’t infer it from the flow’s behavior.

Enterprise SSO (SAML)

If the operator has enabled Enterprise SAML SSO (a commercial feature), some users sign in through their company’s corporate IdP instead of with a Forseti password. This is transparent to your app. You keep doing plain OIDC against Forseti — an SSO’d user arrives as an ordinary session and id_token, with the same claims as any other user. There’s nothing SAML-specific to handle on your side.

A few things worth knowing:

  • “Sign in with your company” links. SAML is per-org, and each connected org has a deep-link of the form https://<forseti>/sso/{org-slug}. If you want to offer a company-branded entry point, you can link users straight to it — the operator gives each org its URL. Otherwise users just sign in normally and Forseti routes them.
  • Org-aware authz is the orgs claim, same as always. SSO doesn’t introduce a new authz mechanism: an SSO’d user is a member of the org they signed in through, and that membership shows up in the org / orgs claims exactly like any other member’s. Use those for tenant scoping — see the scope reference and Organizations.
  • SSO sessions are AAL1. The second factor happens at the corporate IdP; Forseti doesn’t see it and doesn’t reflect it in acr. If your app gates a sensitive operation on acr/AAL2 (see AAL step-up), an SSO’d user will need a second factor enrolled in Kratos to clear it — the IdP’s MFA doesn’t count.

The operator-side setup, JIT provisioning, and linking semantics live in commercial/saml.md.

Forseti discovery document

Everything OAuth/OIDC-shaped lives on Hydra’s /.well-known/openid-configuration. Forseti-specific surfaces — deep-link entry, account-management URI, the JWKS that signs outbound webhooks — are advertised on a separate document Forseti serves itself:

GET https://accounts.example.com/.well-known/forseti-configuration
{
  "issuer": "https://accounts.example.com",
  "forseti_version": "0.x.y",
  "account_management_uri": "https://accounts.example.com/settings",
  "handoff_endpoint": "https://accounts.example.com/handoff",
  "handoff_actions_supported": [
    "2fa", "password", "profile", "sessions",
    "linked-providers", "authorized-apps"
  ],
  "webhook_jwks_uri": "https://accounts.example.com/.well-known/webhook-jwks.json",
  "webhook_events_supported": [
    "https://schemas.openid.net/secevent/risc/event-type/account-purged"
  ]
}

Cached for an hour at the receiver. Use it to:

  • Drive a “manage account” link without hardcoding /settings (read account_management_uri).
  • Build handoff URLs without hardcoding the action whitelist (read handoff_actions_supported, fall back gracefully if a verb your code expects isn’t listed).
  • Locate the SET-signing JWKS for Account deletion webhooks (read webhook_jwks_uri).
  • Decide which RISC events your receiver should expect (read webhook_events_supported).

The document is deliberately not spliced into Hydra’s OIDC discovery doc — mixing the two muddles the contract and ties Forseti discoverability to Hydra’s response shape. RPs fetch this URL by convention.

When your app wants to send the user into Forseti to perform identity self-service — set up two-factor auth, change their password, manage active sessions — link them at /handoff rather than reverse-engineering Forseti’s internal routes. Forseti validates the request, sets a short-lived banner cookie, and lands the user on the right page with a “Return to ” banner above the navigation so they don’t feel stranded.

https://idp.example.com/handoff
  ?referrer=<your client_id>
  &referrer_uri=<absolute URL to return the user to>
  &action=<verb>

Drop this in an anchor on your account page:

<a href="https://idp.example.com/handoff?referrer=bankapp&referrer_uri=https://bank.app/account&action=2fa">
  Set up two-factor auth
</a>

Parameters

ParamRequiredNotes
referrerwith referrer_uriYour OAuth client_id. Must be a real Hydra client; Forseti looks it up to read client_name and logo_uri for the banner.
referrer_uriwith referrerAbsolute URL the “Return to ” button targets. Its origin (scheme + host + port) must match one of the client’s registered redirect_uris or its client_uri. Origin-binding is the trust gate — it confines the banner’s exit URL to URIs your client legitimately controls.
actionoptionalPublic verb mapped to an internal route (see below). Missing / unknown → /settings (the hub).

referrer and referrer_uri are co-required. If both are absent, the endpoint still works as a stable deep-link target (action → settings route) but no banner is shown. Useful for Forseti-internal emails (“verify your address” links).

Action whitelist

actionForseti route
2fa / totp / mfa/settings/2fa
password/settings/password
profile/settings/profile
sessions/settings/sessions
linked_providers / linked-providers/settings/linked-providers
authorized_apps / authorized-apps/settings/authorized-apps
anything else (or omitted)/settings

Account deletion is intentionally not in the whitelist — users navigate there from inside Forseti’s settings nav, in a context they chose. Sending a user there from an external app would be a confusing UX at best and a social-engineering vector at worst.

The verb → path mapping is the public contract. Internal routes can be renamed without breaking your integration; the action names won’t change. The current verb list is also machine-readable as handoff_actions_supported on the Forseti discovery document.

What the user sees

A bar above Forseti’s navigation:

🏦 Continuing from Bank.    [Return to Bank ↗] [×]

The banner persists on every settings page they navigate to during the session (cookie TTL: 1 hour). Clicking Return to clears the cookie and 302s back to the referrer_uri. The × dismisses globally (cookie is dropped — banner doesn’t come back unless the user re-enters via /handoff).

Validation errors

The endpoint returns 400 Bad Request with a plain-text message in these cases:

  • referrer doesn’t resolve to a Hydra client.
  • referrer_uri’s origin doesn’t appear in the client’s redirect_uris or client_uri.
  • referrer is set without referrer_uri.

Validation failures are audited (app.referrer.entered at warning severity with a failed reason) so operators can spot misconfigured integrations on /admin/audit. A successful “Return to ” click emits app.referrer.returned on the same trail.

What about verify_email?

Email verification lives on a different surface (/verification) that uses the unauthenticated card layout, so the banner doesn’t render there in v1. If your app needs the user to verify an email, send them through the standard OAuth flow with prompt=login — Forseti nags about unverified addresses during sign-in, and Kratos’s default_browser_return_url already brings them back to you on completion.

Protecting an MCP server

If you expose a Model Context Protocol server (so Claude Desktop, Claude Code, or claude.ai can call your tools), Hydra is a natural fit as its authorization server. The MCP 2025-06-18 spec aligns with OAuth 2.1 + OIDC — most of this guide already applies; only the resource-server bits and a handful of client settings are MCP-specific.

Roles

  • Your MCP server is the OAuth2 resource server. It accepts a bearer access token on each request and enforces scopes per tool.
  • Hydra is the authorization server. It mints access tokens after the user signs in at Forseti and consents.
  • The MCP client (Claude Desktop, Claude Code, claude.ai) is a public OAuth2 client. It runs the auth-code + PKCE flow against Hydra exactly like a browser app would.

Nothing Forseti-specific in the topology — this is just OAuth with an MCP-shaped resource server on the end.

How many Hydra clients do I need?

Short answer: one Hydra client per MCP server, regardless of how many Claude hosts (Desktop, Code, claude.ai) connect to it.

Worked examples:

What you’re buildingHydra clientsNotes
App A with a web UI, no MCP1The web client. Confidential, client_secret_post.
App A with a web UI and an MCP server2Web client + MCP client. Different auth methods, scopes, audiences.
Apps A and B, each with web UI + MCP4One per surface. Don’t share an MCP client between apps.
App A, MCP only (no web UI)1Just the MCP client.

The MCP server is a resource server, not a Hydra client — it doesn’t need its own registration.

Two reasons not to share one MCP client across multiple apps:

  • Consent leaks. One client = one consent grant. The user would grant App A’s and App B’s scopes together; revoking access to one revokes both.
  • Audience leaks. A token Claude minted for App A’s MCP server could be re-requested for App B’s without re-consent, because both audiences live on one client.

Multiple Claude hosts (Desktop, Code, claude.ai) hitting the same MCP server share one Hydra client — just register every host’s redirect URI on it. Split them only if you want per-host isolation (e.g. claude.ai’s hosted callback vs a user’s loopback URL are different trust environments). With Dynamic Client Registration enabled (see below), each host self-registers and you don’t pre-create anything.

Registering the MCP client

Ask the operator to register a Hydra client for the MCP host. The admin UI has a dedicated MCP server preset on /admin/clients/new that pre-fills the right defaults — no manual field-twiddling. Settings the preset applies:

  • Token-endpoint auth methodnone. MCP clients are public; they can’t keep a secret.
  • PKCE — required by Hydra whenever auth method is none (and globally enforced via oauth2.pkce.enforced_for_public_clients: true). The client library handles the code-verifier/challenge dance.
  • Redirect URIs — operator pastes the loopback URLs Claude listens on plus, for claude.ai, Anthropic’s hosted callback. The form’s placeholder shows the common ones; treat them as opaque exact-match strings.
  • Scopes — a custom set scoped to your MCP server. Convention: <app>:<resource>:<verb>, e.g. formshive:forms:read, formshive:forms:write. These show up on Forseti’s consent screen with whatever description the operator configured under [oauth.scope_descriptions].
  • Audience allow-list — surfaced as a textarea on the MCP preset. Operator registers your MCP server’s canonical URL there. Hydra accepts an audience query parameter on the auth request (non-standard; RFC 8707 is not yet shipped in Hydra as of v26.2.0). Values passed must appear in this allow-list, and Hydra binds them into the issued access token’s aud claim. Reject tokens whose aud doesn’t match — that’s what stops a token minted for someone else’s MCP server from being replayed at yours.

Dynamic Client Registration. Every forseti deployment exposes RFC 7591 (/oauth2/register) — it’s not optional, because Claude Code refuses to talk to any authorization server whose discovery document omits registration_endpoint. DCR is enabled and anonymous by default: your MCP client (Claude or otherwise) can self-register without any operator coordination upfront.

The safety mechanism is the verification badge, not the registration endpoint. Self-registered clients always land as Unverified. End users see a prominent caution banner on the consent screen (“This application has not been reviewed by an administrator”) until an operator reviews the client at /admin/clients and clicks Mark as verified. There is no auto-promotion path.

Practical implication for MCP authors: just register. No IAT exchange, no operator handshake required to get a working client. Send the request without an Authorization header:

curl -X POST https://accounts.example.com/oauth2/register \
  -H "Content-Type: application/json" \
  -d '{
    "client_name": "My MCP server",
    "redirect_uris": ["http://127.0.0.1:5000/cb"],
    "grant_types": ["authorization_code", "refresh_token"],
    "response_types": ["code"],
    "token_endpoint_auth_method": "none",
    "scope": "openid offline_access"
  }'

Hydra’s response (passed back through Forseti verbatim) carries client_id and a registration_access_token. Keep both. The registration_access_token is what you use for follow-up management calls — GET/PUT/DELETE /oauth2/register/{id} go straight to Hydra, not back through Forseti, because Hydra validates that token itself.

Optional Initial Access Tokens. Operators who want to pre-vouch clients (e.g. partner integrations that should land as Verified) or partition rate limits per tenant can issue an IAT via /admin/dcr-tokens/new and hand it to the MCP author. The author passes it as Authorization: Bearer <iat> on the registration call. IATs are entirely optional — if you don’t have one, omit the header. A malformed Authorization header (wrong scheme, empty token) is rejected with 401 rather than falling through to the anonymous path, so attackers can’t probe IATs silently.

Note: Forseti’s verification badge is independent of the Hydra client metadata. It lives in a Forseti-owned table (oauth_client_metadata) and the consent screen reads it directly from there. RFC 7592 PUT-via-RAT can rewrite Hydra’s view of the client’s metadata, but it cannot influence the verified state shown to end users — that requires an administrator to use the /admin/clients/{id}/verify UI.

See operator-guide.md#dynamic-client-registration-rfc-7591 for the operator-side workflow (review queue, rate limits, reserved-name denylist, optional IAT issuance, audit trail).

Two ways to connect Claude Code

We’ve tested two flows end-to-end. They trade off operator coordination against the consent-screen warning users see — pick based on who’s going to consent.

Flow A — Anonymous DCR (default, lowest friction). Claude Code does the registration itself; no operator coordination needed before first use.

claude mcp add ory-demo http://localhost:8765/mcp --transport http

Then in the Claude session: /mcpAuthenticate. Browser opens to Forseti’s /oauth2/auth, user signs in + consents (with the Unverified caution banner showing). Tokens land in Claude’s keychain. The client appears on /admin/clients as Self-registered + Unverified — operator reviews and clicks Mark as verified to clear the caution banner for future sessions.

Key points:

  • Claude registers a fresh client per claude mcp add invocation.
  • The redirect URI uses an ephemeral loopback port that Claude picks itself.
  • End users see the caution banner on every consent until an operator promotes the client.
  • No operator-side work required upfront — the badge is the safety mechanism.

Flow B — Pre-registered client (production-friendly). Operator creates a Hydra client up-front via the admin UI’s MCP preset, hands the client_id to the MCP-server author. They configure Claude Code:

claude mcp add ory-demo http://localhost:8765/mcp --transport http \
  --client-id 66c22c04-bc73-4364-b0b3-5b7fd07203f2 \
  --callback-port 8080

Requires Claude Code ≥ v2.1.30. The --callback-port flag pins the loopback port so the operator can register a stable redirect_uri like http://localhost:8080/callback on the client.

Key points:

  • The pre-registered client lands as Verified immediately (operator-created = implicit trust).
  • End users see “Reviewed by your administrator” instead of the caution banner.
  • Trade-off: operator and MCP-server author coordinate before first use (one-time).
  • Observed behaviour: even with --client-id, Claude Code still does an anonymous DCR at claude mcp add time (an extra Hydra client gets created but is never used) — minor inefficiency, not a functional break. Filed upstream against Claude Code.

The choice, plainly:

  • Use Flow A for ad-hoc / experimental MCP servers where the friction of pre-registration outweighs the consent-screen warning.
  • Use Flow B for MCP servers shipped to non-technical end users where you can’t afford consent-screen abandonment.

A third flow exists architecturally — IAT-authenticated DCR — but Claude Code can’t drive it today. Forseti accepts Authorization: Bearer <iat> on the registration call (see the Optional Initial Access Tokens note in the DCR callout above), which lets operators pre-vouch a registration with attribution in the audit log. Claude Code has no flag to present an IAT, so this path is useful for other clients — curl-driven provisioning scripts, CI/CD pipelines, custom MCP-author tooling that wraps DCR — not for Claude Code. If your integrator hand-rolls their registration request, an IAT is the cleanest way to mark the resulting client as theirs in the audit trail.

See operator-guide.md#dynamic-client-registration-rfc-7591 for the operator-side workflow on each, including IAT issuance.

What end users see: Verified vs Unverified

Every OAuth2 client carries a verification state the operator manages. DCR-self-registered clients start as Unverified by default — Forseti won’t auto-promote them, because admin review is the safety mechanism that lets us keep DCR open to anonymous self-registration in the first place.

What that means at consent time:

  • Unverified client — the consent screen renders a prominent caution banner: “This application has not been reviewed by an administrator. Only proceed if you trust it.” End users see this every time they consent. Technical users may shrug; non-technical users tend to abandon.
  • Verified client — a subtle green checkmark instead. Operator-created clients (anyone using /admin/clients/new in the admin UI) are Verified by default, since the act of an admin creating it is the vouching.

The path to a green checkmark is straightforward: once you’ve DCR-registered, point the operator at your client in /admin/clients. They eyeball the redirect URIs, scopes, and client_name, then click Mark as verified. The operator can also revoke verification later via Revoke verification on the show page (POSTs /admin/clients/{id}/unverify) — the consent banner snaps back to the caution copy on the next consent request.

Don’t ship your MCP server to non-technical end users with an Unverified client. Either run the verification handshake with the operator first, or expect a noticeable drop-off at consent. See operator-guide.md for the operator’s verify / unverify workflow.

What your MCP server needs to implement

Two HTTP endpoints + one header. That’s the entire OAuth surface; everything else is MCP protocol.

1. GET /.well-known/oauth-protected-resource — a static JSON document advertising your authorization server and the scopes you accept. Detail and sample document in Resource discovery below. Two things worth getting right on first try:

  • Publish offline_access in scopes_supported (the OIDC Core 1.0 §11 standard name), not Hydra’s legacy offline alias. Claude Code reads this list to compose its DCR scope field; if you publish offline, the registered client can’t later request offline_access and Hydra rejects with invalid_scope. We hit this exact mismatch in our end-to-end tests — costs nothing to get right up front.
  • bearer_methods_supported: ["header"]Authorization: Bearer <token> is the only method modern clients use. The other RFC 6750 methods (query param, form body) are deprecated and discouraged.

2. Your MCP endpoint (path is yours; the convention is POST /mcp). On every request:

  • No Authorization header → 401 with WWW-Authenticate: Bearer realm="<your-realm>", resource_metadata="<absolute URL to your /.well-known/oauth-protected-resource>". The client reads this to find your AS and start the OAuth dance.
  • Bearer token present → validate it locally by verifying the JWT signature against Hydra’s JWKS (see Token validation below for the full checklist: signature, aud, exp, scope). Invalid token → 401 with the same WWW-Authenticate header. Insufficient scope → 403 with WWW-Authenticate: Bearer error="insufficient_scope", scope="<required>".
  • Valid → process the MCP request (initialize, tools/list, tools/call, etc).

3. MCP protocol over the same endpoint. Use whichever MCP SDK fits your language (TypeScript, Python — Anthropic ships both) or implement the JSON-RPC subset directly. The OAuth layer is independent of the protocol layer; they just share the endpoint.

That’s the full implementation footprint. A working reference exists — we built one in ~150 lines of stdlib Python during this project’s testing (POST /mcp + the well-known doc + JWKS-based bearer validation + one echo tool). Production servers add observability, real tools, and persistence on top, but the OAuth/discovery surface stays exactly this size.

Resource discovery

Claude needs to find Hydra. The MCP spec uses Protected Resource Metadata (RFC 9728): when an unauthenticated request hits your MCP server, respond with 401 and a WWW-Authenticate: Bearer resource_metadata="https://yourapp.com/.well-known/oauth-protected-resource" header.

That metadata document points at Hydra:

{
  "resource": "https://mcp.yourapp.com",
  "authorization_servers": ["https://hydra.example.com"],
  "scopes_supported": ["formshive:forms:read", "formshive:forms:write"],
  "bearer_methods_supported": ["header"]
}

The client follows authorization_servers[0] to Hydra’s OIDC discovery doc and runs the standard auth-code + PKCE flow from there.

Token validation in the MCP server

Hydra issues JWT access tokens by default, with a 5-minute TTL. You validate them locally by verifying the JWT signature against Hydra’s JWKS — no network call to the AS on the hot path, no admin-API reachability needed.

Why JWT + local validation is the recommended path:

  • Resource servers can live anywhere. All you need is a public reach to https://hydra.example.com/.well-known/jwks.json. Serverless, third-party VPC, customer-hosted — doesn’t matter.
  • Revocation lag is bounded to 5 minutes. The short TTL is the whole point. Once a user revokes Claude’s grant at Forseti, the refresh exchange fails and the worst-case window before the agent stops working is the access-token TTL.
  • Same validation shape as the id_token — fetch JWKS, cache by kid, verify RS256, validate iss / aud / exp / nbf. If you’ve already implemented Validating the id_token, you’ve already implemented this.

Verification checklist:

  1. Signature — verify against Hydra’s JWKS (<issuer>/.well-known/jwks.json). Cache the keyset with a ~24h TTL; refetch on unknown kid.
  2. iss — equals the issuer configured in hydra.yml (urls.self.issuer). Pin this value; don’t trust the JWT body to tell you who signed it.
  3. aud — contains your MCP server URL (the audience binding from the client’s allowlist; see Audience allow-list in the operator guide).
  4. exp — not in the past (with a few seconds of clock skew).
  5. scope — covers what the tool requires.

Reject with 401 and WWW-Authenticate: Bearer error="invalid_token" on signature / aud / exp failures; reject with 403 and error="insufficient_scope", scope="<required>" on scope failures. Claude reads these and either refreshes or prompts the user to re-consent.

Hydra emits typ: JWT today, not RFC 9068’s typ: at+jwt. If you use a strict validator, configure it to accept JWT here.

A typical Hydra-issued JWT access token payload:

{
  "iss": "https://hydra.example.com",
  "sub": "f8c9d0e1-...",
  "aud": ["https://mcp.yourapp.com"],
  "scope": "formshive:forms:read formshive:forms:write",
  "exp": 1700000300,
  "iat": 1700000000,
  "jti": "5d7c3a91-2f04-4d8b-9e2c-a3b1d6f0e842",
  "client_id": "claude-mcp-client-id",
  "acr": "aal1",
  "ext": { /* portal-injected extras, if any */ }
}

Cache jti for the token’s lifetime on high-value endpoints — RFC 9068 mandates jti precisely so resource servers can detect replay of a captured token (defence beyond just exp).

Alternative: opaque + introspection (private-network only)

If you need true sub-minute revocation — e.g. a regulated environment where a 5-minute revocation lag is unacceptable — ask the operator to flip strategies.access_token: opaque in hydra.yml. Be clear-eyed about what you’re signing up for:

Opaque token validation requires calling Hydra’s introspection endpoint on the admin API (/admin/oauth2/introspect on :4445). The admin API is private and MUST NOT be exposed to the public internet. Your MCP server therefore needs a route into the operator’s internal network — service mesh, private link, VPC peering, whichever your platform calls it.

That constraint is the reason JWT is the default. If your MCP server runs on Cloudflare Workers, Vercel, a third-party SaaS, or anywhere else without a tunnel into the operator’s admin network, opaque tokens are not an option for you — and the operator can’t just “open up the admin API” to fix it, because that endpoint isn’t authenticated at the application layer; the private network IS the auth boundary.

If you do have admin-network access, the introspection call looks like this:

POST /admin/oauth2/introspect HTTP/1.1
Host: hydra-admin.internal:4445
Content-Type: application/x-www-form-urlencoded

token=<the access token>

Response is RFC 7662 standard; checklist is the same as for JWT but you replace “verify signature + iss” with “verify active == true”. Cache positive responses for 5–30 seconds if you need throughput, but be conservative — caching defeats the immediate-revocation benefit that’s the entire reason to pick opaque in the first place.

Step-up for high-risk tools (experimental)

Best-effort, not verified. As of January 2026, none of the major MCP clients (Claude Desktop, Claude Code, claude.ai, ChatGPT) publicly document RFC 9470 challenge handling. The pattern below is what the spec asks for; whether your client honours it is the open question. Implement it as defense-in-depth, but don’t assume it’ll trigger a fresh auth flow without testing against the specific client.

For destructive or irreversible MCP tools (delete data, transfer funds, manage credentials), pair the scope check with an AAL check. If the token’s acr is aal1, reject with 401 and a RFC 9470-shaped challenge:

WWW-Authenticate: Bearer error="insufficient_user_authentication",
  error_description="A second factor is required for this tool",
  acr_values="aal2"

A spec-compliant client reads acr_values from the challenge and re-runs the auth-code flow with acr_values=aal2, which Forseti honors per AAL step-up. The returned token’s acr is now aal2 and the tool call succeeds. A non-compliant client treats the 401 as a generic auth failure and may simply give up — fall back to a clear error_description that an end user can act on.

Things not to do

  • Don’t accept opaque secrets in headers as a substitute for OAuth tokens. If your MCP server takes an API key, you’ve opted out of the user’s portal identity and lost every benefit (revocation, audit, consent, AAL).
  • Don’t trust sub for authorization beyond identity. The user’s current permissions belong in scopes; sub is just the stable identifier.
  • Don’t skip aud validation. Without it, any Hydra-issued access token works at your MCP server, including tokens minted for unrelated clients.
  • Don’t ship your MCP server to end users before asking the operator to verify your DCR-registered client. The consent screen shows a prominent “unverified application” caution until an admin clicks “Mark as verified.” Non-technical end users may abandon at consent.

Known issues and further reading

  • Scope name inconsistency in Claude Code. Claude Code reads scopes_supported from the resource server for DCR, then augments the auth-URL scope with offline_access per OIDC spec. If the resource server advertises offline instead of offline_access, the registered client’s scope list doesn’t include offline_access and Hydra rejects with invalid_scope (anthropics/claude-code#4540 — same Hydra-backed AS as ours). Fix: publish offline_access (the OIDC standard name) in your MCP server’s scopes_supported, not offline.
  • Duplicate DCR with --client-id. Even when configured with a pre-registered client_id, Claude Code still does an anonymous DCR call at claude mcp add time. Leaks an unused Hydra client per add invocation. Auth flow itself uses the pre-registered id correctly.
  • FAST_JWT_MALFORMED (or any “not a valid JWT”) on token validation. Means the operator has switched Hydra to opaque access tokens (strategies.access_token: opaque) but your MCP server is still trying to verify them as JWTs. Either switch your server to use Hydra’s admin introspection endpoint (private network only — see the opaque alternative above), or ask the operator to revert to the JWT default.
  • active: false on introspection of every token. The inverse: operator is on the JWT default but your MCP server is calling introspection. Stop introspecting; verify the JWT against <issuer>/.well-known/jwks.json instead.
  • Testing without a browser. Hydra’s admin API lets you accept login and consent challenges programmatically (PUT /admin/oauth2/auth/requests/login/accept and PUT /admin/oauth2/auth/requests/consent/accept) with a synthetic subject. Useful for end-to-end tests of your MCP server’s token flow without standing up a real Kratos identity.

Further reading:

Account deletion webhooks

If you store a local copy of user data keyed by the sub claim, Forseti will tell you when a user self-deletes so you can clear your copy.

How it works

When a signed-in user deletes their account from /settings/account/delete, Forseti:

  1. Enumerates every OAuth2 client they have an active consent grant with.
  2. For each client whose metadata.forseti.account_deletion_url is set, Forseti POSTs an RFC 8417 Security Event Token (a signed JWT, EdDSA / Ed25519 per RFC 8037) carrying a single RISC account-purged event to that URL.
  3. Retries on failure with exponential backoff (1m × 2^attempt, ±25 % jitter, capped at 6 h). Up to 12 attempts or 72 h total — whichever fires first marks the row dead-lettered.

Direction is one-way: portal → app. Apps cannot initiate identity deletion; only the user (from Forseti) or an operator (via /admin/identities) can.

The wire format and event vocabulary match Google’s Cross-Account Protection — if you already verify RISC events from Google, you can point the same handler at Forseti and the only thing that changes is the issuer URL on the JWT and the JWKS to verify against.

Registering a deletion endpoint

  1. Ask the operator to set account_deletion_url on your OAuth2 client at /admin/clients/{id}. The field accepts an HTTPS URL — that’s the only knob.
  2. Stand up an HTTPS endpoint that:
    • Accepts POSTs from Forseti’s egress address. Forseti validates account_deletion_url at save time — http://, hostnames that resolve to loopback / link-local / RFC1918 / IMDS addresses, and anything reachable only via redirect through such ranges are rejected.
    • Reads the body as a compact JWS (Content-Type: application/secevent+jwt), verifies the signature against Forseti’s JWKS, and validates the claims (see below).
    • Returns 2xx on success. Anything else triggers a retry. 3xx responses are not followed — the worker disables redirects.

There’s no shared secret to mint or exchange. Forseti owns one Ed25519 signing key per installation; receivers verify with the matching public JWK, exactly like Hydra-issued id_tokens.

Payload shape

The body is a compact JWS. Header:

{ "alg": "EdDSA", "typ": "secevent+jwt", "kid": "<stable per-portal>" }

Decoded claims:

{
  "iss": "https://portal.example.com",
  "aud": "<receiver client_id>",
  "iat": 1747824225,
  "jti": "f0c8a9e2-3b5d-4e1c-8f9a-1234567890ab",
  "events": {
    "https://schemas.openid.net/secevent/risc/event-type/account-purged": {
      "subject": {
        "subject_type": "iss-sub",
        "iss": "https://portal.example.com",
        "sub": "<kratos identity id, matches `sub` in id_tokens>"
      }
    }
  }
}

iss is Forseti’s own externally reachable URL — same value Hydra puts on id_tokens for its issuer. aud is your client_id, so you can validate it with the same value you already pin for token verification.

Validating the SET

On each delivery:

  1. Fetch Forseti’s signing JWKS from https://portal.example.com/.well-known/webhook-jwks.json (also advertised as webhook_jwks_uri on the Forseti discovery document). The endpoint advertises Cache-Control: max-age=86400; cache locally by kid and refetch on miss.
  2. Read the kid from the incoming JWT header, look it up in your cached JWKS, and verify the signature with EdDSA (Ed25519).
  3. Check claims:
    • iss equals Forseti’s URL you’ve configured (pin this string; don’t trust the JWT body to tell you who it’s from).
    • aud equals your OAuth2 client_id.
    • events carries the key https://schemas.openid.net/secevent/risc/event-type/account-purged.
    • events[..].subject.sub is the subject you want to purge.
  4. Dedupe on jti — it’s the event id, stable across retries.

Verifying the SET (Node example)

Use a library. The example below uses jose, which ships JWKS fetching, caching, and JWT verification in a single function call:

import * as jose from "jose";

const PORTAL = "https://portal.example.com";
const AUDIENCE = "<your-client-id>";
const ACCOUNT_PURGED =
  "https://schemas.openid.net/secevent/risc/event-type/account-purged";

const JWKS = jose.createRemoteJWKSet(
  new URL(`${PORTAL}/.well-known/webhook-jwks.json`)
);

export async function handleAccountPurged(req, body) {
  const { payload } = await jose.jwtVerify(body, JWKS, {
    issuer: PORTAL,
    audience: AUDIENCE,
    typ: "secevent+jwt",
    algorithms: ["EdDSA"],
  });
  const event = payload.events?.[ACCOUNT_PURGED];
  if (!event) throw new Error("not an account-purged SET");
  const sub = event.subject?.sub;
  if (!sub) throw new Error("missing subject.sub");
  // Dedupe on jti — same event_id repeats across retries.
  if (await alreadyProcessed(payload.jti)) return 200;
  await purgeUser(sub);
  await recordProcessed(payload.jti);
  return 200;
}

Every other ecosystem has a comparable library — Python’s PyJWT with PyJWKClient, Go’s github.com/golang-jwt/jwt plus a JWKS fetcher, Java’s nimbus-jose-jwt. Match the same shape: fetch JWKS by URL, cache by kid, verify EdDSA (Ed25519), validate iss + aud + the RISC event URI.

Headers

Each delivery carries one portal-specific header:

  • X-Portal-Event: <jti> — for body-less dedupe across retries, mirrors the jti claim inside the SET. Use whichever is more convenient.

The body itself is the compact JWS; there’s no separate transport-level signature. Replay protection lives inside the SET (signature binds iat + jti).

Idempotency and retries

  • jti is a UUIDv4 unique per delete event, repeated across retries of that same event. Dedupe on it server-side.
  • Receivers should be idempotent: if you’ve seen the jti before, return 2xx immediately.
  • Forseti retries until it gets a 2xx, exhausts attempts, or hits the 72 h max age.

Signing key rotation

Forseti-side signing key is operator-managed — drop a fresh PEM at [webhook].signing_key_path and restart. Receivers don’t need to do anything special: cache JWKS by kid and refetch on miss. Same pattern you already use for id_token JWKS.

Eventual-consistency fallback

If you don’t register a webhook, or your webhook ultimately dead-letters, you’ll still notice eventually: Hydra rejects token-refresh attempts for deleted subjects (Forseti revokes consent sessions as part of the delete saga). Webhooks are the active notification; refresh-failure is the passive safety net.

Local fallback during IdP outage

Treat the IdP as a hard dependency for sign-in, not for every user action. When accounts.example.com is unreachable:

  • Users who already have a session in your app continue working until their session expires.
  • Users who need to sign in are blocked.

Mitigations to keep your app usable during an IdP outage:

  • Long-lived application sessions (refresh proactively, but tolerate refresh failures within a grace window).
  • Alternative auth paths: API keys, signed magic links, or a break-glass admin login that does not depend on the IdP.
  • Cache the JWKS aggressively. Token verification keeps working even if Hydra is briefly unreachable.

Forseti is not a single point of failure if your app degrades gracefully.

Scope reference

Standard OIDC scopes

ScopeEffect
openidRequired for OIDC. Causes Hydra to return an id_token.
emailAdds email and email_verified claims.
profileAdds name, given_name, family_name (if present in the identity), plus preferred_username and updated_at when the user set a handle.
offline_accessAdds a refresh_token to the token response. Hydra also accepts the bare offline alias for back-compat — prefer offline_access (OIDC Core 1.0 §11).
orgAdds an org claim — { id, slug, role, name }. When the auth request carries organization_id=<id>, the claim is pinned to that org (or omitted entirely if the user isn’t a member — see below); otherwise it reflects the user’s currently-active org (the signed active_org cookie, else their first membership).
orgsAdds an orgs claim — an array of { id, slug, role, name } — listing every org the user belongs to. Capped at 32 entries. Apps that show a tenant picker request this.
groupsAdds a groups claim, a flat array of the user’s team slugs in their active org, for apps that map group names to roles (Parseable, Grafana, Argo CD). Empty array when the user has no teams. Capped at 200 with a groups_truncated flag. Scoped to the active org.
profile (extended)When [profiles].enabled = true on Forseti, profile additionally surfaces picture (avatar URL) and website from the user’s portal-owned profile. Standard OIDC slots — apps already requesting profile pick these up with no client-side change. Missing/empty fields are simply omitted.
extended_profilePortal-owned non-standard claims: bio, pronouns, and links (array of {label, url}). Only added when [profiles].enabled is on AND the user filled the fields. Request alongside profile when you want the full profile block. Revocation is whole-grant — see /settings/authorized-apps.

Active-org selection (org scope)

When a downstream app needs to scope an authentication to a specific org, it includes organization_id=<id-or-slug> on the /oauth2/auth URL alongside the usual OAuth2 parameters: either the org’s stable id or its human-friendly slug works, Forseti resolves whichever you send to the same canonical org. It’s a plain query parameter, so it survives Hydra’s redirect chain untouched and reaches Forseti at the login and consent steps. organization_id is a private-use, Forseti-specific extension parameter, not part of the OIDC spec; treat it that way if you’re building your own authorize URL rather than relying on a library default.

What happens next depends on the signed-in subject’s membership in the pinned org:

  1. Already a member: the org (and groups) claim is pinned to that org for this token, and the login step pre-selects it via the signed active_org cookie, regardless of which org the user last switched to in the portal. No extra step, no prompt.
  2. Not a member, and the org is public (access mode external with public login enabled): before the login completes, Forseti shows a one-time “Join <Org>?” confirmation page. The user can confirm (they join as a member, then the login finishes with the org claim pinned to that org) or continue without joining (the login finishes with no org/groups claim for that org). This also covers a brand-new registrant who followed a pinned link: they land in the Default org first, then hit this same confirmation on their way back into the flow. Once a user has joined, they’re never prompted again for that org.
  3. Not a member, and the org is private (invite-only) or the reference doesn’t resolve: the pin is silently ignored, no error UX, no interstitial, login proceeds with no org/groups claim for that org. Placement into a private org still only happens via invite acceptance or domain auto-join, unaffected by this parameter.

With no organization_id on the request, the claim reflects the user’s currently-active org (the active_org cookie, else their first membership).

When to pin, and when not to. The pin only sets the singular org/groups (active-org) claim and can trigger the join interstitial. It does not narrow the plural orgs claim, which always carries the user’s full membership list. So the pin is the right tool for a single-tenant app, or an app deployed once per tenant, where every login should be scoped to one org and new users funneled into it. It is the wrong tool for a multi-tenant app that reads the full orgs list and manages org context itself (its own org switcher, per-workspace routing, and so on): there the pin does nothing useful for the app (the app derives memberships from the full orgs claim and picks the active org its own way), and it actively adds friction, since every user who is not already a member of the pinned org sees a one-time “Join <Org>?” prompt on login, and if they accept, they are moved out of their Default org. Such apps should omit organization_id and consume the orgs list. If you build a client library or SDK that sets this parameter, default it to unset and document it as a single-tenant knob.

Example auth URL:

https://hydra.example.com/oauth2/auth?\
  client_id=acme-app\
  &response_type=code\
  &scope=openid%20email%20org\
  &redirect_uri=https://app.example.com/callback\
  &organization_id=acme\
  &state=...

The resulting id_token carries:

{
  "iss": "https://hydra.example.com/",
  "sub": "01234567-...",
  "email": "alice@acme.example.com",
  "org": {
    "id": "acme",
    "slug": "acme",
    "role": "owner",
    "name": "Acme Co"
  }
}

Apps that need the full picker (e.g. “switch tenant” dropdown) request org orgs together.

Group-based roles (groups scope)

Apps that derive roles from group membership (Parseable, Grafana, Argo CD, Kubernetes) request groups. Forseti emits a flat array of the user’s team slugs in the active org:

{
  "groups": ["platform", "sre"]
}

Create teams in Forseti (Organizations, then Teams) and a matching role per slug in the downstream app. The claim is scoped to the active org, so a groups-only token carries no org discriminator; request org alongside it if the app needs to know which org the slugs belong to. Slugs are unique only within an org, not globally, so for a user in multiple orgs the same slug can map to different teams in different orgs. An app that derives roles from bare slugs should request org and key its role mapping on the (org, slug) pair, or restrict the client to a single org. Group changes propagate on the user’s next sign-in or app authorization; they are not refreshed mid-session via the refresh-token grant.

Custom scopes

Operators can register additional scopes when creating the client. Use them for app-specific permission grants (e.g. formshive:forms:read, formshive:mcp:write).

Custom scope semantics are opaque to Hydra and Forseti — they appear in the issued access token’s scope claim, and your resource server is responsible for enforcing them. Document the meaning of each custom scope on your side; the consent screen shows them with whatever description the operator configured under [oauth.scope_descriptions] in Forseti config (see operator-guide.md).

Further reading

Commercial Features

Forseti’s OSS core is everything you need to run a self-service identity portal in front of Kratos and Hydra. A commercial license unlocks a small set of features aimed at teams running Forseti for more than one tenant or wiring it into a corporate identity provider.

This page is the buyer/operator overview: what the license unlocks, how the offline licensing model works, and where the free/paid line sits. For the operator and integrator detail of each feature, follow the links below.

What a license unlocks today

A commercial license unlocks a small set of features. They all ship and work today:

  • Organizations — run Forseti for more than one tenant: named orgs beyond the always-free default, per-org membership and invites, per-org branding, an org-scoped admin slice, and the OIDC org / orgs claims for org-aware authorization.
  • Enterprise SAML SSO — per-org SAML login (/sso/{org-slug}) against a customer’s corporate identity provider, with just-in-time provisioning and verified-email linking. Your apps keep doing plain OIDC.
  • Observability - a Prometheus /metrics endpoint on the internal listener, token-gated, exposing HTTP RED metrics (request counts, latency) plus a couple of bridged operational gauges. A stock Prometheus, Grafana Agent, or OTel Collector scrapes it as-is.
  • Linux authentication — higher seat cap. The Linux-authentication core (back your Linux hosts’ login accounts off the identity store) is free; a commercial license raises how many accounts you can provision. See Linux authentication below for exactly where the free/paid line sits.

Other capability names you might see referenced (SCIM provisioning, SIEM streaming, bulk admin) are planned, not shipped — they don’t work yet, so don’t plan around them.

How licensing works

Licensing is offline. Forseti never calls out to validate a license; verification is fully offline. There’s no license server and no outbound call at runtime — which matters if you self-host in a network that can’t (or won’t) reach the internet, including air-gapped deployments.

A license is a small signed file. You activate it by pasting the blob at /admin/license. Forseti verifies the signature itself, reads the customer name, expiry, the list of enabled features, and an optional cap on the number of orgs, then stores it. Each feature is unlocked independently — a license that includes Organizations but not SAML unlocks exactly that.

Active, grace, and locked

A license can carry an expiry (lifetime licenses never expire). Forseti checks the license against the clock at startup and whenever you activate one, putting each licensed feature into one of three states:

StateWhat it means operationally
ActiveLicensed and before expiry. The feature works normally — reads and writes.
GracePast expiry but still inside the grace period. The feature goes read-only: existing data stays accessible and existing logins (including SAML SSO) keep working, but new writes — creating another org, minting an invite to a named org, creating or toggling a SAML connection — are blocked.
LockedNo license, the license doesn’t include this feature, or the grace period has passed. The feature shows an upgrade prompt; the gated surface is unavailable.

The grace period is a safety net: if a renewal is forgotten, your production deployment doesn’t break the moment the license expires — existing users keep logging in, and you can’t accidentally lose access to data you already created. You just can’t add new paid resources (new orgs, SAML connections) until you renew, and then it hard-locks. The window is a fixed 30 days of read-only operation after expiry and is not operator-configurable.

The feature set, expiry, and org cap come from the signed license itself, not from config — editing config.toml can’t widen what a license grants.

Free vs paid

The boundary is deliberately simple:

OSS (unlicensed)Commercial
Default orgFull read/write — always freeFull read/write
Additional orgsCreate blockedUp to the license cap
Org branding, invites to named orgsn/a (only Default exists)Yes
Org-scoped adminn/aOwners manage their own org
org / orgs OIDC claimsDefault-onlyFull membership
SAML SSO (/sso/{slug})UnavailablePer-org connections
Prometheus /metricsUnavailable (404)Internal listener, token-gated
Linux auth core (resolver, host enrollment, SSH keys)FullFull
Linux POSIX accountsUp to the free seat capUp to the license’s seat cap
Team-scoped Linux host accessn/a (whole-org only)Scope hosts to org teams

OSS ships exactly one real default org and every code path treats it like any other org — there’s no stubbed single-tenant mode. The license simply lets you add more orgs and switch SAML on, so an unlicensed deployment is always a fully working single tenant.

Linux authentication

Linux authentication is a hybrid: the capability is free, and a license raises one specific limit.

  • Free / OSS. Everything operational: the resolver that serves passwd/group/SSH-key data to your hosts, host enrollment (and secret rotation/revocation), adding SSH keys, and provisioning POSIX accounts up to the free seat cap (free_seats, default 25). A single-machine or small-fleet operator never needs a license to run Linux auth.
  • Commercial — higher seat cap. A license carrying the Linux-authentication feature raises the cap from free_seats to the license’s seat allowance, so you can provision more accounts. This is the only thing the license changes about Linux auth.
  • Resolution is never gated. Whatever your license state, an already-provisioned account keeps resolving — a lapsed or missing license can stop you adding accounts but can never lock an existing user out of a machine. In the 30-day grace window after expiry, the cap falls back to the free tier for new provisioning (consistent with grace being read-only), while existing accounts keep working.
  • Team-scoped host access needs Organizations. A host always belongs to one org and can resolve that org as a whole on Linux auth alone. Scoping a host to specific teams within the org (finer-grained host access) requires the Organizations feature — it’s part of the multi-org capability, not Linux auth on its own. Membership is resolved live at request time; there is no group-mirroring step.

The operator-facing how-to (enrolling hosts, provisioning accounts, the seat cap in practice) is in the operator guide → Linux authentication.

The licensing split

Forseti is dual-licensed:

  • The OSS core — everything that runs Forseti as a single-tenant portal — is AGPL-3.0-or-later. See LICENSE.
  • The commercial gate — the code that enforces the paid feature flags — is the proprietary, source-available Forseti Commercial License 1.0. See LICENSE-COMMERCIAL.

The gate is source-available so you can audit exactly what it does (it’s an offline signature check, nothing more), but it isn’t AGPL — running the paid features requires a license. The README’s License section is the canonical statement.

Organizations

Commercial feature — additional organizations require a license that includes the orgs capability. The default org is always free. See Commercial features for the licensing model.

Organizations let you run Forseti for more than one tenant: named orgs, per-org membership and invites, per-org branding, an org-scoped admin slice, and the OIDC org / orgs claims so your apps can do org-aware authorization.

Forseti is multi-org from the ground up. OSS ships exactly one org — the always-free Default org — and a commercial license unlocks the rest. There’s no separate “single-tenant mode”: the Default org is a real org and behaves like any other, so OSS users get a fully working single-tenant deployment with nothing stubbed out.

On the app side, Stackpit (a self-hosted, single-binary Sentry alternative) is a first-class consumer of these claims: it maps your Forseti orgs and their owner/member roles straight into its own per-org access model, so SSO users land in the right org with the right role automatically. If you want to see the org claims doing real work in a downstream app, that’s the reference pairing.

For app developers consuming org claims over OIDC, see the integration guide. For the implementation details, see dev/organizations-internals.md.

Free vs paid

OSS (unlicensed)Business (orgs feature)
Default org✅ full read/write
Additional orgs❌ create blocked✅ up to the license cap
Invites to Default org
Invites to named orgs
Org-scoped adminn/a (only Default exists)✅ owners manage their own org
org / orgs OIDC claimsDefault-onlyfull membership

The maximum number of orgs comes from your license, not from config. Unlicensed deployments are capped at the single Default org; a license raises the cap to whatever it grants.

Roles

Every membership is one of two roles:

  • Owner — runs governance for the org: rename it, edit branding, invite and remove members, change member roles, delete the org, and use the org-scoped admin surface for it.
  • Member — belongs to the org and gets it in their OIDC claims, but has read-only access to org-scoped resources.

Membership

Placement is never silent. There are three explicit ways into an org, plus one automatic “home” org for people who belong to none:

  • Invite — into any org; the invitee must have a verified email (see Invites).
  • Domain auto-join — an internal org that has proven it owns an email domain and opted into auto-join will offer anyone with a verified address at that domain a one-click prompt to join. No invite needed, but it is a prompt, not a silent join.
  • Public self-serve — an external org’s public page (/o/<slug>) lets anyone register and join it directly (see Access modes).

The Default org is the home floor: a user who belongs to no other org is automatically a member of it, and is moved out of it once they join a real org (and back in if they later leave their last one). Anyone whose email is on the operator’s admin allowlist is always an owner of Default; everyone else is a member. If the allowlist is empty, Default has no owner (the same state in which the operator admin panel is inaccessible).

A user can belong to several orgs at once and switch between them from the org dropdown in the nav.

When is a verified email required?

Only where membership is derived from the email itself. Domain auto-join trusts your email’s domain, so it requires that specific address to be verified. Invite acceptance also requires verification (a deliberate belt-and-braces control, since an invite link is a secret that could leak). Public self-serve and the Default home floor do not require verification — you registered for that specific org explicitly, or it is just your catch-all home, so the email is not the credential. Operators who want stricter public onboarding can force email verification at the identity layer.

Invites

Owners add people to a named org by inviting them:

  1. The owner opens the org’s members page and invites an email address, choosing the role (owner or member) the invite grants.
  2. Forseti emails the invitee a link. Invites expire after a configurable window (7 days by default).
  3. The invitee opens the link. If they’re not signed in, they’re walked through registration first; if they’re signed in with the wrong email, they’re told to sign out and retry. Otherwise they confirm and join.

Only verified email addresses can accept an invite — the invitee must have confirmed their email with Forseti before they can join. A leaked or forwarded invite link can’t be replayed once it’s expired or already been accepted.

Teams

A team is a named subset of an org’s members. Teams are a commercial feature everywhere, including the Default org: managing them requires a license with the Organizations capability.

Owners manage teams from the org’s Teams page (/settings/organization/teams, or /settings/organizations/<slug>/teams for a named org). From there an owner can create a team, rename or delete it, and add or remove org members. Only people who already belong to the org can be added to its teams.

Teams do two things:

  • Member visibility. With the same_group member-visibility policy, members can see each other in the directory only when they share at least one team. Teams are how you carve up who sees whom.
  • Host scoping. When you enroll a Linux host, you pick which org it belongs to and then scope it either to the whole org (any member may log in) or to one or more of that org’s teams (only members of those teams may log in, and they’re grouped together on the host). A host belongs to exactly one org, fixed at enrollment: you can change its team scope later, but not its org.

Deleting a team removes it from any host that was scoped to it; the host falls back to whatever scope remains (whole-org if it had no other teams).

A member’s public profile page surfaces the teams they belong to, so people can see how they’re organized. Owners viewing another member’s profile see only the teams that sit in orgs they own. For Linux hosts, a member can see which hosts their own account can reach, and a Forseti operator can see the same for any account from the admin surface; org owners deliberately can’t enumerate another org’s reachable hosts.

Branding

Each org can carry its own theme, logo, and support email. When set, these override the global [brand], and the active org’s theme white-labels the whole authenticated app — not just its login screen — so each tenant sees their own look. Owners edit this at /settings/organization/branding.

  • Theme — a preset (default, midnight, or cyberpunk, each with an auto-derived dark mode) plus optional brand colours (primary, on-primary, secondary) entered as hex. The dark-mode palette is contrast-checked.
  • Logo — either an HTTPS URL (private, loopback, and cloud-metadata addresses are rejected) or an uploaded image: PNG, JPEG, or WebP, up to 256 KB, validated by its magic bytes (not the declared type) and served by Forseti at /branding/{slug}/logo.
  • Support email — a single well-formed address, shown on help and error pages.
  • Public login — a toggle that publishes the org’s landing page at /o/{slug} (see Access modes below).

Access modes

Every non-Default org is internal by default: invite-only, no public presence. An owner can switch a named org to external (a licensed, Orgs-feature capability — the Default org can never be external), which unlocks self-serve public signup:

  • A public landing page at /o/<slug>, themed with the org’s branding, with a “Create an account” CTA.
  • A /join/confirm flow: a visitor registers (or signs in) and explicitly confirms joining as a member — no invite needed.

The /o/<slug> landing page also carries a “Sign in” link for any org with branding enabled, including internal ones (signing in doesn’t carry the self-serve signup restriction, it just takes a returning member straight to a login pre-selected for their org). The “Create an account” CTA stays gated to external orgs with public login on; an unresolvable, internal, or disabled slug falls back to the plain, unbranded landing page either way, so the page never confirms that a given slug exists.

Switching to external automatically applies two defaults: the member directory is set to administrators-only and public login is turned on. The administrators-only directory is hard-enforced for external orgs — an owner cannot loosen it to a more open visibility policy while the org stays external, and an attempt to do so is rejected and recorded in the audit log. Switching back to internal lifts the restriction.

Both the public landing page and the registration flow are per-IP and globally rate-limited (see the operator guide for the specifics and their limitations).

Routing app logins into a specific org

An app wiring its OIDC login against Forseti can send its users straight into one of your orgs by adding organization_id=<id-or-slug> to its authorize request: hand the app’s developer the org’s id or slug and they do the rest; there’s nothing to configure on your side. What happens next depends on the org’s access mode:

  • Public org (external, public login on): a returning member is placed straight into that org, no prompt. A non-member, including someone signing up for the first time through that app, sees a one-time “Join <Org>?” confirmation before their login completes, so joining always happens as an explicit step, never silently. Once they’ve joined, they’re never asked again.
  • Private org (internal, or public login off): the pin has no effect for a non-member: they log in with whatever context they already have, and the only ways into the org remain an invite or (if enabled) domain auto-join. A private org can’t be self-serve-joined through this parameter, regardless of what an app sends.

This is what lets an app like Stackpit brand its login for one tenant and have new signups land as members of that tenant automatically, without you minting an invite for every one of them. See the integration guide for the parameter’s app-developer-facing details.

Org-scoped admin

An org owner gets a scoped slice of the admin surface for their own org without being a Forseti-wide operator. That lets a tenant owner manage their org’s OAuth clients, identities, sessions, and audit trail — filtered to that org, never anyone else’s — while the global operator surface stays gated behind the operator’s admin allowlist.

Owners reach their org’s admin view from the org settings; the Forseti operator continues to see the full, unfiltered surface.

OIDC claims

Two OIDC scopes surface org membership to your apps:

  • org — a single object describing the user’s currently active org: its id, slug, role, and name.
  • orgs — an array of every org the user belongs to, each with id, slug, role, and name. Request this when an app needs a tenant picker.

Both also appear at the userinfo endpoint. Apps that don’t request either scope get nothing extra, so plain openid email logins are unaffected. The full app-facing reference — including how to pin the active org at login and example tokens — is in the integration guide’s scope reference.

A membership claim is not identity proof. A user can be in the Default home org or in an open external org without a verified email, so relying apps must never treat an org claim (least of all org.id = "default") as evidence of who the user is, and must never authorize on email without checking email_verified. See the integration guide.

Enterprise SSO

Organizations are also the tenancy unit for commercial SAML SSO: each org can carry one operator-managed SAML connection, giving its members a /sso/{org-slug} login URL against your corporate IdP. Org owners see a read-only “Enterprise SSO” status line on their org’s overview page; the operator manages connections. See Enterprise SAML SSO.

Configuration

The optional [orgs] table tunes cookie/invite timeouts, the landing-page and logo rate limits, the domain-verification methods, and the per-org domain ceiling (every key has a default, so the table can be omitted). The two you’ll reach for most:

[orgs]
active_org_cookie_ttl_seconds = 2592000   # 30 days — how long the active-org selection is remembered per browser
invite_ttl_days = 7                        # how long a minted invite stays redeemable

The full key list (rate limits, domain-verification toggles, domain_max_per_org, and the rest) is in the operator guide’s [orgs] reference.

The maximum number of orgs (max_orgs) is not a config knob — it comes from the license blob. There are no org-specific CLI commands: invites simply expire in place, and deleting an identity automatically removes all of its memberships.

Enterprise SAML SSO

Commercial feature — requires a license that includes the saml capability. See Commercial features for the licensing model.

Per-org SAML login at /sso/{org-slug}. Forseti doesn’t speak SAML itself — it drives a SAML Jackson / Ory Polis bridge that you deploy alongside it. Jackson handles assertion validation, signatures, and IdP quirks; Forseti talks plain OAuth2 to Jackson and owns identity resolution, org membership, and session establishment.

Prerequisites

  • A commercial license that includes the saml feature (activate at /admin/license).
  • A deployed Jackson / Ory Polis instance reachable by both browsers and the Forseti server.
  • Kratos with the recovery link method enabled — Forseti establishes the post-SSO session via an admin-minted recovery (magic) link, and Kratos refuses to mint one without:
selfservice:
  methods:
    link:
      enabled: true

Without this, every SSO login fails at the final step. The playground’s infra/kratos/kratos.yml already has it.

[saml] configuration

[saml]
jackson_url = "https://sso.example.com"
jackson_internal_url = "http://jackson:5225"  # optional server-to-server override
jackson_api_key = "change-me"                 # one of Jackson's JACKSON_API_KEYS
client_secret_verifier = "change-me"          # Jackson's CLIENT_SECRET_VERIFIER
identity_schema_id = "default"                # Kratos schema for JIT identities
sp_entity_id = "https://saml.boxyhq.com"      # SP entity id; must match Jackson's samlAudience
  • jackson_url — browser-facing base URL of the Jackson instance. Also used to derive the ACS URL shown on /admin/saml.
  • jackson_internal_url — optional container-network address for server-to-server calls (token, userinfo, connection CRUD). Defaults to jackson_url.
  • jackson_api_key — must match an entry in Jackson’s JACKSON_API_KEYS; authorises connection create/delete against Jackson’s admin API.
  • client_secret_verifier — must match Jackson’s CLIENT_SECRET_VERIFIER; it’s the OAuth2 client secret paired with the dynamic per-org client id.
  • identity_schema_id — the Kratos identity schema used for JIT-provisioned identities. Default "default".
  • sp_entity_id — optional; the SP entity id shown on /admin/saml and handed to the customer’s IdP admin. Defaults to https://saml.boxyhq.com. Override it to match Jackson’s samlAudience if you’ve changed that — otherwise the page shows a stale value and assertion-audience checks fail.

The table is strictly opt-in: leave it out and the /sso/* routes aren’t even mounted.

Deploying Jackson

The playground service in infra/docker-compose.yml (profile saml, brought up via make stack-up-saml) is the reference for the minimum env:

environment:
  - EXTERNAL_URL=http://127.0.0.1:5225        # browser-facing URL — match [saml].jackson_url
  - JACKSON_API_KEYS=dev-jackson-api-key      # match [saml].jackson_api_key
  - CLIENT_SECRET_VERIFIER=dev-client-secret-verifier  # match [saml].client_secret_verifier
  - DB_ENGINE=sql
  - DB_TYPE=postgres
  - DB_URL=postgres://jackson:secret@postgres:5432/jackson?sslmode=disable
  - NEXTAUTH_SECRET=dev-nextauth-secret-32-chars-xx
  - NEXTAUTH_URL=http://127.0.0.1:5225
  - NEXTAUTH_ADMIN_CREDENTIALS=admin@example.com:secret
  - BOXYHQ_NO_ANALYTICS=1

For production hardening (TLS, DB choice, secrets), follow the Ory Polis deployment docs — Forseti has no opinion beyond the URLs and the two shared secrets. One playground caveat: make stack-down does not remove the saml-profile containers; remove them explicitly if you need a truly clean slate.

Creating a connection

Connections are operator-managed at /admin/saml (Forseti-tier admin only — org owners see a read-only status line on their org’s overview page instead). One connection per org.

/admin/saml/new takes the org, a display name, and the IdP metadata as either a metadata URL or a raw XML paste. Jackson 26.x only fetches metadata URLs that are localhost or HTTPS — for an IdP serving plain-HTTP metadata, paste the XML.

Hand the customer’s IdP admin the SP values shown on the /admin/saml list page:

  • ACS URL{jackson_url}/api/oauth/saml
  • SP entity id — Jackson’s samlAudience, default https://saml.boxyhq.com

The per-org SSO URL

Each connected org gets https://<forseti>/sso/{org-slug} — that’s the contract you give the customer. They wire it into their IdP portal, intranet bookmarks, or wherever their users start from. It’s the only entry point: the flow is SP-initiated, and a login started at the IdP side won’t land.

Any reason the URL can’t start a login (unknown slug, no connection, connection disabled, license locked) renders one uniform “SSO unavailable” page — outsiders can’t probe which orgs have SSO configured.

The enable/disable toggle on /admin/saml is an instant kill switch: disabled connections render that same neutral page. Delete removes the connection from Jackson first (IdP metadata included), then the local record and its email links.

JIT provisioning and linking

Forseti resolves the assertion to a Kratos identity, in order:

  1. Durable subject link — a prior SSO login recorded a link keyed on the stable SAML subject (NameID) for this org; reuse that identity. This is the primary key, so logins survive an email change at the IdP. Stale links (identity since deleted) are pruned automatically.
  2. Existing email link — a legacy or bootstrap link for this (org, email) pair; reuse that identity and backfill its subject so step 0 carries it next time.
  3. Verified-email match — an existing identity whose verified address matches is linked on first SSO login and used from then on.
  4. JIT create — no match: a new identity is created via the Kratos admin API with the email pre-verified, under [saml].identity_schema_id.

Every link records the SAML subject alongside the email, so the (org, subject) pair becomes the durable key once a user has logged in at least once.

Three cases fail closed to a blocked page (no session is established):

  • Cross-org non-member — a verified identity matches the asserted email but isn’t yet a member of this org. Because Kratos identities and sessions are global, auto-linking would let one org’s IdP assert another org’s user’s email and obtain a session as them. So a pre-existing verified identity is only linked when it’s already a member of this org. To let an existing user sign in via a new org’s SSO, pre-add them as a member first (invite or /admin); net-new users (no Kratos identity yet) are JIT-created and joined automatically.
  • Unverified match — an existing identity holds the email but hasn’t verified it. Linking would let an IdP assertion capture a squatted-but-unproven account. The user must verify the address (or you reap it via unverified-prune) before SSO works.
  • Email conflict — the JIT create hits a 409 because an identity holds the address in a way the verified-lookup didn’t surface (e.g. imported or passwordless identities). Resolve manually via /admin/identities.

Successful logins also ensure org membership: the identity is added to the org as member if not already a member.

Audit trail: saml.login.succeeded / saml.login.failed / saml.login.blocked_unverified, saml.identity.jit_created / saml.identity.linked, and admin.saml.connection_created / _toggled / _deleted for the admin surface.

Session semantics

SSO sessions are established by redeeming a short-lived (15-minute) Kratos recovery link, so the browser ends up with a native ory_kratos_session cookie — no parallel session machinery. Two consequences:

  • Sessions are AAL1. MFA happens at the corporate IdP; Forseti doesn’t see it and doesn’t step the session up. AAL2-gated surfaces (the admin UI) still require a second factor enrolled in Kratos.
  • Users land on the dashboard, not the password-change page Kratos normally shows after recovery — Forseti intercepts that landing and bounces them home.

Grace period

When the license is past expiry but inside the fixed 30-day grace window, SSO logins keep working — you don’t lock a customer’s workforce out over a lapsed renewal — but connection management (/admin/saml create/toggle/delete) goes read-only. Past grace, logins render the neutral unavailable page and the admin surface shows the upsell.

Not in v1

  • IdP-initiated logins (SP-initiated only).
  • SAML Single Logout — Forseti logout ends the Kratos session; the IdP session survives.
  • More than one connection per org.
  • Self-serve connection management for org owners — connections are operator-managed; org owners get a read-only status line.
  • Per-connection subject override. Linking keys on the stable SAML subject (NameID) once a user has logged in once, so email changes at the IdP are handled transparently — the durable link survives. The caveat: IdPs configured to send a transient or email-format NameID give an unstable or email-derived subject, and those connections fall back to email keying (so an email change orphans the link, as before). Pinning linking to a per-connection immutable attribute (e.g. an IdP objectGUID) regardless of NameID format is a future enhancement, not in v1.
  • Organizations — orgs are the tenancy unit each SAML connection attaches to.
  • Flow internals — sequence diagrams and handler references.
  • Integration guide — what SAML means for downstream apps (spoiler: nothing — they keep doing OIDC).

Observability (Prometheus metrics)

Commercial feature: requires a license that includes the observability capability. See Commercial features for the licensing model.

A Prometheus-format /metrics endpoint, served on the internal listener only. It’s meant for a stock Prometheus, Grafana Agent, or OTel Collector Prometheus receiver to scrape as-is; no exporter or bridge to run yourself.

Prerequisites

  • A commercial license that includes the observability feature (activate at /admin/license).
  • The internal listener configured ([internal].bind in config.toml): /metrics is never served on the public listener.
  • A scrape token (see below).

Enabling it

  1. Activate a license carrying observability at /admin/license.
  2. Set a scrape token in config.toml:
[metrics]
scrape_token = "change-me"

FORSETI_METRICS__SCRAPE_TOKEN overrides it via environment, same as any other Figment-backed setting. Leave the table out (or the token unset) and /metrics stays disabled, 404, even on a licensed deployment.

See config.example.toml for the [internal] and [metrics] entries side by side.

What it exposes

MetricTypeMeaning
http_requests_total{method,path,status}counterRequest count across all listeners (public, internal, admin), labeled by method, matched route template, and status code.
http_request_duration_secondshistogramRequest latency, same labels as above.
forseti_audit_write_failures_totalcounterAudit log write failures, bridged from the in-process audit writer.
forseti_last_kratos_webhook_timestamp_secondsgaugeUnix timestamp of the last Kratos webhook Forseti processed.

Labels are bounded by design: method is drawn from an allowlist (anything else collapses to OTHER), path is the matched route template (e.g. /orgs/:slug, never the raw URL), and status is the numeric HTTP status. There are no per-tenant, per-org, or per-identity labels, so cardinality stays fixed regardless of how many orgs or users you run.

Scraping it

Point a standard Prometheus at the internal bind with the bearer token:

scrape_configs:
  - job_name: forseti
    scheme: http
    static_configs:
      - targets: ["forseti-internal:8081"]
    authorization:
      type: Bearer
      credentials: "change-me"

Swap the target for wherever [internal].bind actually listens in your deployment, and the credentials for the configured scrape_token. No other Prometheus-side config is needed; the endpoint is plain text exposition format (text/plain; version=0.0.4).

Access control and exposure

The endpoint fails closed at every gate: only a licensed feature AND a matching token together return data.

ConditionResponse
Feature not Active or Grace (unlicensed, wrong license, past grace)404
No scrape_token configured404
Token configured but request has no/wrong Authorization: Bearer401
Feature licensed AND token matches200, metrics body

The two 404 cases are deliberate: an unlicensed or untokened deployment doesn’t reveal that the feature exists at all. The bearer comparison is constant-time (SHA-256 then a subtle equality check), so there’s no length or timing oracle on the token.

The token is defence-in-depth, not the only control. /metrics is bound to the internal listener, which is meant to stay off the public network path, but in some container setups the internal bind can still be reachable from other containers or the host network. Keep it network-restricted (firewall, container network policy, or a proxy that only your scraper can reach) rather than relying on the token alone.

Grace period

Metrics are read-only telemetry, so during the fixed 30-day grace window after license expiry, /metrics keeps serving like any other read path. Past grace, it 404s the same as an unlicensed deployment.