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.
Set these on the API server before registering a provider:
| Variable | Why 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_URL | The 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_ORIGINS | Comma-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.
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.
The response includes a redirectURI — that's the callback URL to paste into
your IdP's application configuration. It's built as:
e.g. https://api.example.com/auth/sso/callback/acme-okta.
@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.
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.
Same endpoint, POST /auth/sso/register, with samlConfig instead of
oidcConfig:
Most SAML IdPs can import SP metadata directly rather than being hand-configured. After registering, fetch it from:
If your IdP needs an explicit ACS (assertion consumer service) URL instead,
the default (when samlConfig.callbackUrl isn't set) is:
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.
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 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).Once forceSso is true, POST /auth/sign-in/email for a matching-domain
email is rejected with:
(packages/auth/src/auth-instance.ts's before hook — a raw, case-insensitive
domain match against ssoProvider.domain on every /sign-in/email call.)
@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.
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.revokeSessionsOnPasswordReset: true).POST /auth/revoke-session,
/auth/revoke-sessions (all of them), /auth/revoke-other-sessions (all but
the current one).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.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.
ENCRYPTION_KEYENCRYPTION_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.
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.
ENCRYPTION_KEY, BETTER_AUTH_URL, TRUSTED_ORIGINS, TERRANTULA_DEPLOYMENT.@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.