Single sign-on (SSO)

Terrantula ships per-organization SSO — OIDC and SAML — via better-auth's sso() plugin, wired in packages/auth/src/auth-instance.ts. It's part of the Apache 2.0 backend and runs the same way in oss and cloud deployment mode; nothing about SSO is a hosted-only feature. This guide is for whoever operates the self-hosted API server (Maya, Devon, or Sam-shaped teams standing up their own instance) and needs to put an existing OIDC or SAML identity provider (Okta, Azure AD, Google Workspace, Keycloak, or anything OIDC/SAML-compliant) in front of sign-in for one organization.

The CLI has no sso command. The sibling UI repo (terrantula-ui) does ship a working registration/list/delete/force-toggle panel (app/components/SsoPanel.tsx, rendered from the org settings page) that calls the same /auth/sso/* and /orgs/:id/sso-extras endpoints described below — but the panel only exposes OIDC fields; there's no SAML form in the UI, so SAML registration is API-only regardless. That panel is wrapped in a <CloudFeature> component that only dims/disables itself when the UI is built with VITE_DEPLOYMENT=oss — and cloud is the build flag's default (app/lib/deployment.ts), so a self-hoster running an unmodified UI build gets a working SSO panel unless they specifically opt into an OSS build. Everything below is the direct API path — useful for scripting, CI, SAML registration, or an OSS UI build where the panel is disabled.

Prerequisites

Set these on the API server before registering a provider:

VariableWhy SSO needs it
ENCRYPTION_KEY (or ENCRYPTION_KEY_SSM_PARAM)32-byte hex key (openssl rand -hex 32), or the name of an AWS SSM parameter holding one. The IdP client secret (OIDC) and SP private key (SAML) are encrypted with it before they ever reach the database — see Storage. Without either set, registering a provider fails (see below).
BETTER_AUTH_URLThe public origin of the API, e.g. https://api.example.com. This is what the callback/redirect URLs you give your IdP are built from.
TRUSTED_ORIGINSComma-separated origins the auth layer will trust for OIDC discovery URLs and post-login redirects. Must include the origin your IdP's discovery/authorization/token/JWKS endpoints live on, and the origin(s) you pass as callbackURL when signing in. See Troubleshooting.

All three are documented in the Configuration Reference.

Registering an OIDC provider

POST /auth/sso/register (requires a signed-in session — cookie or a bearer session token, since the bearer() plugin is enabled). If you pass organizationId, the caller must be an owner or admin of that org; a plain member is rejected with 403. Reading, updating, and deleting an org-linked provider are restricted to owner/admin too, so the whole org-linked provider lifecycle is owner/admin-only.

curl -X POST https://api.example.com/auth/sso/register \
  -H "Authorization: Bearer <session-token>" \
  -H "Content-Type: application/json" \
  -d '{
    "providerId": "acme-okta",
    "issuer": "https://acme.okta.com",
    "domain": "acme.com",
    "organizationId": "org_abc123",
    "oidcConfig": {
      "clientId": "0oa1b2c3d4e5f6",
      "clientSecret": "REPLACE_WITH_YOUR_OIDC_CLIENT_SECRET",
      "scopes": ["openid", "email", "profile"]
    }
  }'

The response includes a redirectURI — that's the callback URL to paste into your IdP's application configuration. It's built as:

<BETTER_AUTH_URL>/auth/sso/callback/<providerId>

e.g. https://api.example.com/auth/sso/callback/acme-okta.

The register response carries no credentials

@better-auth/sso@1.6.25's register endpoint echoes back the whole provider it just created, clientSecret and SAML private keys included. Terrantula strips that before the response leaves the process, so oidcConfig comes back with the endpoints, clientId, scopes and pkce but no clientSecret, and samlConfig comes back without privateKey, decryptionPvk or the private-key fields nested under idpMetadata / spMetadata.

The filter is an allowlist of published fields, not a list of secrets to remove, so a config field a future plugin version adds is withheld until it is named. If you need the full stored config back you cannot have it — nothing reads it out in plaintext except the sign-in flow itself. GET /auth/sso/get-provider gives the same sanitized view, with clientId masked to clientIdLastFour

.

Once registered, users sign in via POST /auth/sign-in/sso, supplying whichever of providerId, domain, or email you have. The plugin resolves the provider in priority order: providerId first, then domain — email only contributes a domain when no explicit domain is given, it is not tried first. See Troubleshooting for the exact domain-matching rules and what happens when nothing resolves. On success this returns a redirect URL to your IdP's authorization endpoint.

Registering a SAML provider

Same endpoint, POST /auth/sso/register, with samlConfig instead of oidcConfig:

curl -X POST https://api.example.com/auth/sso/register \
  -H "Authorization: Bearer <session-token>" \
  -H "Content-Type: application/json" \
  -d '{
    "providerId": "acme-adfs",
    "issuer": "https://adfs.acme.com",
    "domain": "acme.com",
    "organizationId": "org_abc123",
    "samlConfig": {
      "entryPoint": "https://adfs.acme.com/adfs/ls",
      "cert": "REPLACE_WITH_YOUR_IDP_SIGNING_CERT_PEM",
      "callbackUrl": "https://api.example.com/auth/sso/saml2/sp/acs/acme-adfs",
      "spMetadata": { "entityID": "https://api.example.com" }
    }
  }'

Most SAML IdPs can import SP metadata directly rather than being hand-configured. After registering, fetch it from:

GET <BETTER_AUTH_URL>/auth/sso/saml2/sp/metadata?providerId=<providerId>

If your IdP needs an explicit ACS (assertion consumer service) URL instead, the default (when samlConfig.callbackUrl isn't set) is:

<BETTER_AUTH_URL>/auth/sso/saml2/sp/acs/<providerId>

There's also a combined GET/POST callback endpoint at <BETTER_AUTH_URL>/auth/sso/saml2/callback/<providerId> that supports both IdP-initiated and SP-initiated flows. Which one your specific IdP wants (ACS vs. the combined callback) is IdP-dependent — confirm against the value your IdP actually needs, and cross-check against spMetadataUrl in GET /auth/sso/get-provider's response for the provider once registered.

How org linkage works

ssoProvider.organizationId (in packages/auth/src/schema.ts) is what scopes a provider to one org. When a user signs in through an org-linked provider for the first time, better-auth auto-provisions their org membership with the role from organizationProvisioning.defaultRole, which this codebase hardcodes to 'member' (getRole is not set, so there's no per-provider override) — an SSO-provisioned user always lands as an org member, never admin/owner, regardless of what the IdP claims about them. Promote them afterward through the ordinary org member-role API if they need more.

Force-SSO

Force-SSO makes password sign-in fail for any email at a domain your org has claimed, so users must go through the SSO redirect instead.

  • GET /orgs/:id/sso-extras — lists { providerId, forceSso } for every SSO provider linked to the org. Owner/admin only.
  • PATCH /orgs/:id/sso-extras/:providerId — sets forceSso for one provider. Owner/admin only; scoped to the calling org (a provider belonging to a different org 404s even if you know its providerId).
curl -X PATCH https://api.example.com/orgs/acme/sso-extras/acme-okta \
  -H "Authorization: Bearer <session-token>" \
  -H "Content-Type: application/json" \
  -d '{"forceSso": true}'

Once forceSso is true, POST /auth/sign-in/email for a matching-domain email is rejected with:

403 "<domain> requires Single Sign-On. Use "Sign in with SSO"."

(packages/auth/src/auth-instance.ts's before hook — a raw, case-insensitive domain match against ssoProvider.domain on every /sign-in/email call.)

Force-SSO's domain match is stricter than SSO's own domain match

@better-auth/sso's own sign-in-with-SSO domain resolution supports comma-separated multi-domain values on a single provider (domain: "acme.com,subsidiary.com") and matches subdomains. The force-SSO hook does not — it does an exact lower(domain) = <email domain> comparison against the whole column value. If you register a provider with multiple comma-separated domains, force-SSO will only block password sign-in for emails whose domain equals the entirestored string, not for any one of the listed domains individually. Register one provider per domain if you need force-SSO to work per-domain.

Session lifetime, refresh, and revocation

  • Sessions last 7 days from creation (expiresIn), and slide forward another 7 days on any authenticated request once at least a day has passed since the last refresh (updateAge: 1 day) — so an actively-used session never expires, and an idle one lapses within a day of the 7-day mark.
  • A short-lived session cookie cache (5 minutes) avoids re-hitting the session table on every request.
  • Every session for a user is revoked when they reset their password (revokeSessionsOnPasswordReset: true).
  • Any signed-in user can revoke their own sessions: POST /auth/revoke-session, /auth/revoke-sessions (all of them), /auth/revoke-other-sessions (all but the current one).
  • An admin (via the admin plugin, gated on the session:revoke permission) can revoke another user's sessions: POST /auth/admin/revoke-user-session, /auth/admin/revoke-user-sessions.
  • POST /auth/admin/ban-user deletes every session for the banned user as a side effect, in addition to blocking future sign-in.

Storage: what actually happens at rest

ssoProvider.oidcConfig (carries the IdP clientSecret) and ssoProvider.samlConfig (carries the SP private key, if you supply one) are encrypted with AES-256-GCM under ENCRYPTION_KEY, at the database-adapter boundary (packages/auth/src/encrypted-adapter.ts) — every read/write the @better-auth/sso plugin makes to that table passes through it transparently. The plugin itself never sees ciphertext; it reads and writes plain config objects, and the wrapper encrypts on the way in and decrypts on the way out.

That last clause is why the register response needed its own filter (above): the create path decrypts too, so encryption at rest never had anything to say about what the register endpoint hands back.

If you're upgrading a database that already had ssoProvider rows in plaintext (written before this encryption wrapper existed), the API runs a one-shot, idempotent backfill on every start (backfillSsoConfigEncryption, called from apps/api/src/server.ts) that finds any still-plaintext row and encrypts it in place. It's safe to run repeatedly — already-encrypted rows are skipped by shape, not by a trial decrypt.

Rotating ENCRYPTION_KEY is not free. The backfill converts plaintext to ciphertext under the current key; it does not re-encrypt ciphertext that was already encrypted under a previous key. If you rotate ENCRYPTION_KEY without a separate re-encryption pass, every previously-registered oidcConfig/samlConfig becomes undecryptable — that provider's sign-in flow will break — until you either restore the old key or manually re-register those providers under the new one. Treat ENCRYPTION_KEY as long-lived, same as the Configuration Reference already says for every other credential it protects.

If you forgetENCRYPTION_KEY

ENCRYPTION_KEY (or ENCRYPTION_KEY_SSM_PARAM) is required — there's no plaintext fallback. If it's unset and you try to register an SSO provider, the encrypting adapter's create() call throws before any row is written (so you don't end up with a half-written provider), but that thrown error isn't a recognized API error type — it surfaces to the caller as a generic 500 {"error":"Internal server error","code":"INTERNAL_ERROR"}, not a descriptive "ENCRYPTION_KEY is missing" message. Check the server log for Either ENCRYPTION_KEY or ENCRYPTION_KEY_SSM_PARAM must be set if a provider registration call fails with an opaque 500.

Troubleshooting

Redirect URI mismatch at the IdP. The OIDC callback is exactly <BETTER_AUTH_URL>/auth/sso/callback/<providerId> — copy it from the redirectURI field in the register response rather than reconstructing it by hand, and make sure your IdP app's configured redirect URI matches it character-for-character (trailing slashes and http vs. https both count).

Domain doesn't resolve to a provider. Sign-in-with-SSO looks a provider up by providerId, then domain (exact match, then comma-list/subdomain match) — in that order. If none match, you get 404 "No provider found for the issuer". Double check the domain value you registered the provider with.

TRUSTED_ORIGINS omissions break OIDC discovery. Unless you registered the provider with oidcConfig.skipDiscovery: true and supplied authorizationEndpoint/tokenEndpoint/jwksEndpoint yourself, registration (and, if any of those three are still missing, every sign-in) fetches the IdP's .well-known/openid-configuration and validates every endpoint URL in it against TRUSTED_ORIGINS. An origin that isn't listed fails registration with 400 "Untrusted OIDC discovery URL: ..." — add the IdP's origin (and any distinct origin its discovery document points to) to TRUSTED_ORIGINS.

  • Configuration Reference — every server env var, including ENCRYPTION_KEY, BETTER_AUTH_URL, TRUSTED_ORIGINS, TERRANTULA_DEPLOYMENT.
  • RBAC & Drift — org roles (owner/admin/member) referenced throughout this guide.
  • Orgs API — org and member-role management.

Two upstream endpoints Terrantula does not serve

@better-auth/sso ships two surfaces this deployment refuses, both because they are cross-organization reads that row-level security cannot bound:

  • GET /auth/sso/providers answers 404. It reads every ssoProvider row and then filters it by checking the caller's membership across an arbitrary set of organizations at once; member is org-scoped under RLS, so there is no single organization the query can be scoped to. Administer a provider through its own organization instead.
  • organizationSlug on POST /auth/sign-in/sso answers 400. Use providerId, domain, or email — a provider found by organization is the same provider providerId addresses directly.

Both are permanent, not temporary, and both are refused inside better-auth (packages/auth/src/auth-instance.ts) rather than at the HTTP mount, so an in-process call is refused on the same terms. Each refusal is declared there with its reason.