Self-Serve SAML SSO Setup
Register a SAML provider, prove ownership of an email domain by DNS TXT record or emailed token, and activate federation from the dashboard, with the real button labels, error strings and the state of end-user sign-in today.
Overview
A tenant admin sets up SAML 2.0 federation without filing a support ticket. The lifecycle has three phases, each enforced on its own, so a half-finished registration cannot federate logins: register the provider, verify at least one email domain you control, then activate.
End-user sign-in through an activated provider completes. The auth page looks the domain up before redirecting, and that lookup runs server-side under the service role, so a domain verified on an active provider answers has_sso: true and the user is sent to your identity provider.
Registration is a row you insert from the dashboard under a policy that requires tenant admin. Domain verification and activation are two separate functions, neither routed at api.passkeybridge.io; the dashboard invokes them with your signed-in session. Table-level detail and the design rationale are in the SAML SSO domain verification reference.
Prerequisites
- A tenant-admin account. Every step checks that your user is an admin of the tenant, either through the row-level policy on the provider table or through an explicit check inside the function. The self-serve flow carries no plan gate in code, and the SSO tab is available on every plan. The separate legacy connector panel on the same tab is Enterprise-only.
- Your identity provider's federation metadata URL, reachable over HTTPS from the public internet. For Microsoft Entra ID it has the shape
https://login.microsoftonline.com/{tenant id}/federationmetadata/2007-06/federationmetadata.xml. For Okta it is the metadata URL on the SAML application's Sign On tab. - DNS control over the domain you are claiming, or access to its `security@` mailbox.
- The service-provider values your identity provider needs. The auth backend serves them on your project's auth host: the assertion consumer service URL at
/auth/v1/sso/saml/acs, and the SP metadata document at/auth/v1/sso/saml/metadata. Configure your IdP application with those before you activate, so a mismatch surfaces while you are still looking at the screen.
You can register more than one provider per tenant, and attach more than one domain to a provider.
Step 1: register the SAML provider
Open Dashboard > SSO and click "Add provider". There are two fields:
- Display name, 1 to 128 characters, shown to admins only.
- SAML metadata URL, an HTTPS URL of at most 2,048 characters.
Saving inserts a row with status pending, through a row-level policy that requires tenant admin. The metadata URL is stored as text and is not fetched until activation, which keeps that fetch on the server side and stops the registration form from being used as a probe against internal hosts.
The provider then appears in the list with a "Pending" badge, its metadata URL beneath the display name, and icon buttons to activate, disable and remove it. The activate button stays disabled until at least one domain is verified.
Step 2: claim an email domain
In the provider's "Email domains" area, type the domain (for example acme.com), pick "DNS TXT" or "Email" in the method selector, and click "Add domain".
DNS TXT. PasskeyBridge generates a token of 24 random bytes, hex-encoded behind a pb-verify- prefix, and shows the record to publish:
Host _passkeybridge-verify.acme.com
Type TXT
Value pb-verify-0f3a...Publish it at your registrar, wait for propagation, then click "Check DNS". The function resolves the record through Cloudflare's DNS-over-HTTPS resolver and compares a digest of each TXT value against the stored one. On a match the domain is marked verified. Otherwise you get "TXT record not found yet. DNS may take a few minutes to propagate." or "TXT record found but token does not match.", and the attempt counter on the challenge increments.
Email. PasskeyBridge sends a single-use token to security@ at the domain, the RFC 2142 mailbox for the party responsible for it. Whoever receives it passes it to you; paste it into the field and click "Verify token". A token that is unknown, already consumed or expired answers 401 "Token invalid or expired".
Both methods expire after 72 hours and converge on the same verified state. Starting a new challenge does not cancel an earlier one, so an unused token stays valid until it expires or is consumed.
A domain already verified by another tenant is refused as you add it, with 409 "This domain is already verified by another organization. Contact support if you believe this is in error." Two tenants may hold the same domain unverified at once; only the verified state is exclusive.
Consumer domains are a separate matter. Addresses at gmail.com, outlook.com and the like belong to no organization, so they are not claimable here, and those users sign in with the Google or Microsoft social buttons instead.
Step 3: activate the provider
Once a domain shows the "Verified" badge, the activate button on the provider row becomes available. It calls the provisioning function, which confirms you are a tenant admin, re-validates the metadata URL, requires at least one verified domain, and registers the provider with the auth backend's admin SSO API under the service role, binding the verified domains to it. On success the row becomes active and stores the auth-backend provider id.
| Outcome | What you see |
|---|---|
| Success | Toast "Provider activated.", and an "Active" badge on the row |
| No verified domain | 400 "Verify at least one email domain before activating" |
| Metadata URL rejected by validation | 400 naming the reason, for example a non-HTTPS scheme or a private address |
| The auth backend refused it | 502 "Auth provisioning failed. Verify the metadata URL is reachable and well-formed." The row flips to failed and last_error records "Auth API error" with the status and the first part of the response |
| Any failure, in the dashboard | Toast "Failed to activate provider." |
A failed row keeps its configuration, and its activate button becomes a retry. Fix the metadata URL at your identity provider, or make it publicly reachable, and try again.
Activation is reversible. Disable deregisters the provider at the auth backend, clears the stored provider id and sets the row to disabled, keeping your configuration and your verified domains. Remove deletes the provider row, and its domains and verification challenges are deleted with it. The durable record of what happened is Dashboard > Audit log, which keeps sso.domain.verified, sso.provider.activated, sso.provider.updated and sso.provider.removed entries with the actor and timestamp.
A first registration audits as sso.provider.activated; re-running activation on a provider that already holds an auth-backend id audits as sso.provider.updated.
End-user sign-in
On /auth, the "Continue with Microsoft (SSO)" button reveals a work-email field labelled "Work email (we'll route you to your organization's IdP)". On submit the page takes the domain part of the address, lowercases it, and calls shield-sso-lookup through the Supabase functions client. A has_sso: false answer stops there with a message naming the domain; any other answer falls through to signInWithSSO on the auth client, which redirects the user to your identity provider and back to /auth/callback. If the lookup call itself fails, the page proceeds to sign-in and lets the auth client answer. The lookup returns has_sso, display_name and the normalized domain, and never reveals which tenant owns a domain. It runs on every submit; nothing is cached in the browser.
The lookup runs server-side under the service role. shield-sso-lookup accepts a POST, extracts the domain part of the address, checks it against a hostname pattern with a 253-character bound, and calls the security-definer database function with a service-role client. EXECUTE on that function is granted to service_role alone, so a browser cannot call it directly, and the function returns nothing beyond the three fields above.
| Domain | Result |
|---|---|
| Verified domain on an active provider | has_sso: true, redirected to the identity provider, landing on the dashboard afterwards |
| Unrecognized domain | has_sso: false, and an in-product message naming the domain, with no bounce to a provider error page |
| Consumer domain such as gmail.com | Handled by the Google or Microsoft social sign-in buttons instead |
An input that is not a usable domain short-circuits to has_sso: false without touching the database, so a typo in the work-email field reads the same as an unfederated domain.
Troubleshooting
DNS verification keeps failing. TXT propagation takes minutes to hours depending on the registrar. Confirm the record is live from a public resolver with dig TXT _passkeybridge-verify.acme.com before clicking "Check DNS" again, and check the value matches the issued token exactly, with no surrounding quotes and no trailing whitespace. "TXT record found but token does not match." means the lookup succeeded and the value differed.
The email token does not work. Tokens are single-use and expire after 72 hours. Request a new one from the same form. A token that was already consumed, or that belongs to a different domain on your tenant, answers 401.
Activation returns a 502. The auth backend could not retrieve or parse the SAML metadata at the URL you registered. Confirm it is publicly reachable with no IP allowlist, returns valid XML, and uses HTTPS. The row's last_error carries the status the auth backend returned, which usually distinguishes an unreachable URL from a malformed document.
"This domain is already verified by another organization." The domain is verified on a different tenant. Resolving disputed ownership is a manual security review; write to security@passkeybridge.io with proof of control.
"acme.com isn't set up for SSO yet." The lookup answered has_sso: false for that exact domain. Check that the provider row shows "Active" and the domain shows "Verified", and that the address the user typed is at the claimed domain rather than a subdomain of it, since each hostname is a separate claim.
The redirect reaches the identity provider and fails there. The lookup and the activation both worked, and the mismatch is in the service-provider values your IdP holds. Re-check the assertion consumer service URL and the SP metadata document listed under prerequisites against the application configuration at your provider.
Related from the blog
- Physics-Anchored Identity Settlement Tokens: Why the Next Token Primitive Will Prove Presence, Not Stakeengineering · 16 min read
- Is ML-DSA Broken? Three 2026 Headlines, Steelmanned and Correctedsecurity · 14 min read
- DORA Article 28 and Third-Party ICT Risk for Identity Providers: The Subcontracting Chaincompliance · 22 min read