SAML SSO Domain Verification Reference

Reference for the self-serve SAML lifecycle: the three tables, the two verification methods, what the audit trail actually records, the public lookup contract and the security properties the code enforces.

Last reviewed September 16, 2026Fresh

Lifecycle

The self-serve SAML flow has three phases, each with its own authorization layer.

PhaseSurfaceAuthorizationState on success
Provider registrationDirect insert on the providers table, gated by row-level securityAuthenticated tenant admin, through the RLS policypending
Domain claim and verificationshield-sso-verify-domain, with start then check for DNS or confirm for emailAuthenticated tenant admin, checked in the functionverified_at set on the domain
Activationshield-sso-provision, action provisionTenant admin, then a service-role call to the auth backend's admin SSO APIactive

Neither function is on the edge proxy allowlist, so neither answers at api.passkeybridge.io. The dashboard invokes both with your session, and a direct call to the functions host is refused.

Splitting the lifecycle this way means a provider with no verified domain cannot be activated at all, and the activation call is the only path that uses service-role privileges.

Removal uses the same provisioning function with action: "remove", which deregisters the provider at the auth backend, clears the stored provider id and sets the row to disabled. The dashboard's Remove button calls that first and then deletes the row; its Disable button calls it and stops there.

Data model

Three tables back the flow.

shield_tenant_sso_providers holds one row per registered provider, scoped to a tenant: display name, metadata URL, the auth-backend provider id, status (pending, active, failed or disabled), last_error and last_provisioned_at.

shield_tenant_sso_domains holds one row per claimed domain, with verified_at and the verification_method that set it. The domain itself is constrained to lowercase and a valid hostname shape.

shield_tenant_sso_domain_verifications holds one row per challenge: the method, a SHA-256 digest of the challenge token, a digest of the recipient address for email challenges, an attempt counter, expires_at and consumed_at.

All three have row-level security enforcing tenant isolation, and all three are readable by tenant admins only. Providers and domains are writable by tenant admins directly; verification rows are written only by the service role inside the function, which is why the dashboard reads them and never inserts one.

The verification table is working state rather than an audit trail. Rows are updated in place as attempts are made and consumed, they are deleted when their domain or provider is deleted, and a service-role cleanup function exists to delete rows more than seven days past expiry or consumption. That function carries no schedule in this repository, so treat purging as something an operator runs rather than something that happens on its own. The table also has no actor column, so it cannot tell you which admin performed a verification.

The durable record is shield_audit_log, which carries sso.domain.verification_started, sso.domain.verified, sso.provider.activated, sso.provider.updated, sso.provider.provision_failed, sso.provider.provision_blocked and sso.provider.removed, each with the acting user and a timestamp. Read it in Dashboard > Audit log.

Verification methods

DNS TXT. PasskeyBridge issues a token of 24 random bytes, hex-encoded and prefixed with pb-verify-. The admin publishes it as a TXT record at _passkeybridge-verify. followed by the domain. The verify function resolves that name through Cloudflare's DNS-over-HTTPS endpoint, digests each returned TXT value and compares it with the stored digest, and on a match stamps verified_at and verification_method on the domain and consumed_at on the challenge. A mismatch increments the attempt counter on the most recent challenge and returns a status of mismatch; an empty answer returns no_record. DNS is the stronger of the two methods, because it requires control of the zone that also backs the domain's mail routing and its TLS certificate issuance.

Email challenge. PasskeyBridge sends a single-use token to security@ at the domain, the RFC 2142 mailbox for the responsible party and the same address certificate authorities use for domain validation. Only that one address is ever used, so the flow cannot be turned into a mailbox-enumeration tool. The recipient address is stored as a digest, never in the clear. Submitting the token matches it by digest against unconsumed, unexpired challenges for that tenant and those domain ids.

Both methods write to the verifications table, expire after 72 hours, are consumed on first use, and converge on the same verified state. Starting a second challenge does not invalidate the first: an earlier token stays usable until it expires or is consumed.

Tokens are never returned after creation. The DNS value is shown once in the response that creates it, and the email token exists only in the message sent to the domain.

Auth-page lookup contract

The unauthenticated /auth page calls shield-sso-lookup before invoking SSO sign-in. It is a POST that takes either an email address or a bare domain:

{ "email": "person@acme.com" }

The function extracts the domain part, lowercases it, checks it against a hostname pattern with a 253-character bound, and calls a security-definer database function that unions the self-serve provider tables with the auth backend's own SSO domain table. The response is deliberately minimal:

{ "has_sso": true, "display_name": "Acme Corp Entra ID", "domain": "acme.com" }

An input that is not a usable domain short-circuits to has_sso: false without touching the database. Whether acme.com belongs to one tenant or another is not derivable from the response, and no tenant identifier is ever returned.

The function calls the database with the service role. EXECUTE on the lookup function is granted to service_role alone; the grant to anon and authenticated stays revoked from the security-definer lockdown, and the function is trusted server-side code rather than a browser. A verified domain on an active provider answers has_sso: true, and an unknown domain answers has_sso: false. What the service role can reach here is bounded by the handler around it: POST only, one input resolved through the hostname pattern and the length ceiling, and exactly one call, to an RPC whose whole result is a boolean and a display name.

shield-sso-lookup is not on the edge proxy allowlist, so it has no api.passkeybridge.io URL. The browser reaches it through the Supabase functions client with the publishable anon key.

Security properties

Properties this design enforces, stated as they actually hold:

  • Server-side metadata fetch. The SAML metadata URL is stored as text at registration and resolved only by the auth backend at activation, after the provisioning function has validated it against the outbound URL rules. An admin cannot use the registration form to probe internal hosts.
  • Single-use, time-bounded tokens. Challenge tokens are stored as digests, consumed on first use, and expire after 72 hours. A replay after consumption finds no unconsumed challenge and fails.
  • Tenant-isolated lookup. The public lookup returns no tenant identifier, so it cannot be used to map domains to customers.
  • Database-enforced exclusivity on verified domains. A partial unique index covers the domain column where verified_at is set, so two tenants cannot both hold a domain as verified. Unverified claims on the same domain are allowed to coexist, and the second claim is refused at the application layer with a 409 when the first is already verified.
  • Admin-gated activation. The provisioning function checks tenant admin for the calling user against the target tenant before it makes any service-role call, and it refuses to activate a provider with no verified domain.
  • Audited state transitions. Verification and activation write audit rows naming the acting user. The verification table itself keeps no actor and is not append-only, so the audit log is the record to read.

Worth being explicit about the trust boundary: a tenant admin who controls a domain's DNS or its security@ mailbox can federate that domain to their tenant without operator involvement. That is the boundary the design accepts of the design, and it means the strength of the control is the strength of your DNS and mailbox hygiene.

Supported identity providers

Any SAML 2.0 identity provider that publishes a federation metadata document over HTTPS works here. The auth backend parses the metadata and handles the protocol, so PasskeyBridge carries no per-provider code path and no provider-specific configuration.

In practice that includes Microsoft Entra ID, Okta, OneLogin, JumpCloud, Google Workspace SAML and Auth0, each of which exposes a metadata URL from its SAML application configuration. Treat that as a list of providers whose metadata shape is conventional rather than a list of certified integrations: no per-provider conformance testing is recorded in this repository.

The requirements are the ones the code actually enforces. The metadata URL must use HTTPS, must be publicly reachable with no IP allowlist, must not resolve to a private or loopback address, and must be at most 2,048 characters. Your provider also needs the service-provider values from the auth backend, described under prerequisites in Self-serve SAML SSO setup.

There is no self-serve OIDC path. The legacy connector stores OIDC fields and routes no logins, so OIDC federation today means a manual registration by an operator.

Related from the blog