Signet can start an SSO flow from an explicit provider ID, an organization slug, an email address, or a domain. Email/domain and organization discovery use only providers whose DNS domain ownership has been verified. An explicit providerId keeps the existing better-auth-compatible behavior and does not imply that its domain is trusted for discovery.
Register one domain
Register the SAML or OIDC provider with POST /api/auth/sso/register. An organization-linked provider must contain exactly one valid DNS domain. Signet lowercases it, removes a trailing dot, and converts an internationalized hostname to its ASCII form. Register separate providers when an organization controls multiple domains; this prevents one proof bit from covering an unproved second domain.
A SAML provider must resolve an audience distinct from its issuer. Supply samlConfig.audience set to THIS service provider's entity ID, which is the value you configure as the Audience URI at the identity provider, or supply samlConfig.spMetadata. Without either, the expected audience and the expected issuer both fall back to issuer and resolve to the same string, which no assertion can satisfy: every sign-in would fail SAML_AUDIENCE_MISMATCH. Registration refuses that configuration up front with SAML_AUDIENCE_UNRESOLVABLE rather than accepting a provider that can never authenticate anyone.
Registering an organization-linked provider requires the owner or admin role. Being a member is not enough: the provider you register decides who becomes a member of the organization, so registering one is equivalent to deciding who may join. A plain member receives a 403 naming the role required. Domain proof carries the same requirement. For a provider without an organization, only the user who registered it may do either.
Enterprise SSO (the enterprise-sso build feature)
Enterprise SSO — SAML and OIDC both — is compiled OUT by default: a stock binary serves no SSO flow at all. /sso/register, /sign-in/sso, /sso/saml2/callback/{providerId} (SAML) and /sso/callback/{providerId} (OIDC) are not mounted and answer 404 (the domain-ownership proof routes below remain, but no provider can be registered for them to act on). An SSO-capable binary is built with --features enterprise-sso (saml survives as a back-compat alias) and links the system xmlsec1 library for the SAML verifier. An instance that never enables SSO needs nothing installed.
OIDC providers
Register a provider with an oidcConfig (clientId, clientSecret, and either explicit authorizationEndpoint/tokenEndpoint/jwksEndpoint values or nothing beyond the issuer — missing endpoints hydrate from the issuer's /.well-known/openid-configuration at sign-in time). At the IdP, register the redirect URI {base_url}{base_path}/sso/callback/{providerId}. Sign-in sends PKCE (S256) and a nonce, and the callback validates the ID token against the provider's JWKS — issuer, audience, signature, expiry, nonce — before any session is minted. A provider carrying both configs dispatches OIDC first; pass providerType: "saml" at sign-in to pin the SAML transport. Non-public IdP hosts (an internal IdP) must be listed in trusted_origins.
In a saml build, every assertion presented to the callback is cryptographically verified against the provider's configured samlConfig.cert before any session exists: XML-DSig signature (SHA-2 algorithms only), issuer, audience, recipient/destination, status, and validity window are all enforced, assertions are single-use (replay is refused), and SP-initiated flows bind InResponseTo to the AuthnRequest the instance issued.
Two operator-settable samlConfig fields tune the posture:
allowIdpInitiated(defaultfalse): accept responses that carry noInResponseTo. Enable only for IdPs that need IdP-initiated SSO; SP-initiated flows never need it.rejectExpiredCert(defaulttrue): refuse responses when the configured IdP certificate is outside its validity window. SettingsamlConfig.rejectExpiredCert: falserestores the old warn-and-accept behaviour. A certificate whose validity window cannot be evaluated is refused unconditionally, with no opt-out.
Prove DNS ownership
With the managing user's session cookie, request a challenge:
POST /api/auth/sso/request-domain-verification
Content-Type: application/json
{"providerId":"acme-saml"}The 201 response contains txtRecordName, txtRecordValue, and expiresAt. Publish that exact TXT record through the domain's DNS provider, wait for it to resolve, then submit:
POST /api/auth/sso/verify-domain
Content-Type: application/json
{"providerId":"acme-saml"}The challenge remains stable for seven days, so requesting it again does not invalidate a record that an administrator is still publishing. Verification accepts either the full _signet-token-…=… value or the bare challenge token, never a substring. A successful check returns 204. If another provider already proved the normalized domain, the second claim returns 409 DOMAIN_ALREADY_CLAIMED; the database also enforces that invariant during races.
Signet re-resolves the published TXT value when a successful proof becomes seven days old. A successful recheck refreshes the proof timestamp. If DNS no longer returns the exact value, discovery is suspended and the provider must complete a new challenge. Explicit provider-ID selection remains the backward-compatible escape hatch and does not acquire domain trust.
For the bounded Microsoft Entra workforce profile and its current release gates, see Microsoft Entra ID workforce SSO.
Start SSO
The selector precedence is providerId, then organizationSlug, then domain/the domain part of email:
{"callbackURL":"/dashboard","organizationSlug":"acme"}
{"callbackURL":"/dashboard","email":"reader@acme.example"}
{"callbackURL":"/dashboard","domain":"acme.example"}Domain matching is exact. Proving acme.example does not silently claim sub.acme.example; create and prove another provider when a subdomain needs its own discovery path. Failed email/domain discovery always returns the same generic response, whether a provider is absent or awaiting proof. An explicit unknown organization slug returns a distinct error because the caller already named the organization. If an organization has multiple verified providers, include email or domain to disambiguate.
Rotate the identity provider's signing certificate
Every identity provider rotates its signing certificate eventually. Before 2026-08-19 that ended the connection: the assertions stopped verifying, and a provider could not be corrected or removed, so the only repair was registering a new providerId and reconfiguring the identity provider to match a new ACS URL. Correcting and removing a connection landed 2026-08-20. See the two sections below.
POST /api/auth/sso/rotate-certificate/{providerId}
Content-Type: application/json
{ "cert": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----" }PEM or bare base64 DER. The ACS URL does not change, so nothing needs reconfiguring on the identity provider's side.
Four things it does deliberately:
- Owner or admin only for an organization's provider, and the registering user for a personal one. Replacing the anchor re-points who may mint sessions for the whole organization.
- A certificate outside its own validity window is refused (
CERTIFICATE_NOT_CURRENTLY_VALID). Rotating onto a dead certificate would appear to succeed and then lock the connection out at the next sign-in, which is the failure this route exists to prevent. - The old certificate stops being accepted immediately. Signet swaps, it does not overlap. Two live anchors would keep honouring a key you may be rotating precisely because it was stolen. The cost is that an assertion already in flight during the swap fails and is retried.
- Concurrent rotations cannot clobber each other. The update matches the previous configuration, so the loser is refused with
SSO_CERTIFICATE_ROTATED_CONCURRENTLYrather than silently overwriting.
Every rotation is written to the audit log as sso_provider.rotate_certificate.
Related: an expired anchor is now refused at sign-in by default (SAML_CERT_EXPIRED). samlConfig.rejectExpiredCert: false restores the old warn-and-accept behaviour for a customer who knowingly wants it. A certificate whose validity window cannot be evaluated at all is refused unconditionally and has no opt-out.
Correct a connection
A connection registered with one wrong field used to be unfixable: the ACS URL embeds the providerId, so the only repair was a second provider plus a reconfigure at the identity provider. It is now a single call.
POST /api/auth/sso/update-provider
Content-Type: application/json
{ "providerId": "acme-okta", "samlConfig": { "audience": "https://your-sp/metadata" } }Send any of issuer, domain, samlConfig or oidcConfig; at least one is required. The update is partial. Fields you do not send keep their stored values.
providerIdselects the connection and is never itself changed. That is what keeps the ACS URL stable, so a correction needs nothing at the identity provider.- Owner or admin only for an organization's provider, and the registering user for a personal one.
- Identity fields are frozen once people have signed in. Changing
audience,callbackUrl,entryPoint,identifierFormator the IdP/SP metadata while linked accounts exist is refused409. Moving the identity boundary underneath an existing user would re-point them at a different asserted identity. Correcting a connection nobody has used yet, which is the common case, is unaffected. To change those fields later, delete the connection or register a separate one. - Changing the domain resets its DNS proof. The proof you passed was a proof about the old domain. Re-run the verification. Until you do, the connection signs in existing members but creates no new ones.
- A replacement certificate is validity-checked. You may send
samlConfig.certhere, and it is refused withCERTIFICATE_NOT_CURRENTLY_VALIDif it is outside its own window. That is the same guard the rotation route applies, for the same reason. The change is written to the audit log assso_provider.rotate_certificateas well assso_provider.update, so a search for anchor changes finds it whichever route you used. - A correction cannot create a configuration that could never authenticate. The same check registration makes is applied to the merged result (
SAML_AUDIENCE_UNRESOLVABLE). - Concurrent corrections cannot clobber each other. The loser is refused
SSO_PROVIDER_CHANGED_CONCURRENTLYrather than silently overwriting.
The response is the connection with its secrets stripped: no client secret, no private key, no raw certificate. The certificate is reported as its validity window, which is what you need to decide whether to rotate.
Every correction is written to the audit log as sso_provider.update.
Reading a connection back through the API is not built yet. The console shows it, and an API listing is tracked separately.
Remove a connection
POST /api/auth/sso/delete-provider
Content-Type: application/json
{ "providerId": "acme-okta" }Owner or admin only, same as correcting one. In one transaction this unlinks every account bound to the connection, ends the sessions it could sign in, and deletes the connection.
Read that second clause before you use this on a live tenant. Deleting a connection signs out the people who used it. That is deliberate: the case this route exists for is removing a compromised identity provider, and leaving those sessions running would leave the attacker signed in. A user who also has a password loses that session too, because a session does not record which credential minted it. If you are deleting a connection in order to re-register it cleanly, expect everyone to sign in again.
Users and organization memberships are NOT deleted. A person whose only login was that connection still exists and simply has no way in until you give them one, either another connection or a password reset.
Every deletion is written to the audit log as sso_provider.delete, and each ended session emits session.revoked with reason sso_provider_deleted.
Security boundary and current residuals
DNS ownership controls discovery only. Signet does not yet force password, magic-link, social, or password-reset traffic through SSO for a verified domain — treat SSO as an additional authentication path, not a downgrade-resistant organization policy, until that separately tracked control ships. Callback-time membership IS built (2026-08-08): an org-linked provider's sign-in creates the organization membership (role member, once — re-logins never duplicate it), and a failed membership insert refuses the sign-in rather than minting a session outside the org the provider promised. Since 2026-08-19 that membership is only created when the provider's domain is verified: an unverified provider can no longer turn an assertion into organization membership, and the refusal names the two-step domain proof. People already in the organization sign in unaffected, so a provider registered before this rule does not lock out its existing users. The callback's existing account-linking fence remains unchanged: an SSO assertion cannot silently attach itself to a pre-existing local account.