Single Sign-On (SSO) is a common requirement for mid-market and enterprise organizations. It simplifies employee onboarding and offboarding by centralizing authentication through the company’s own identity provider, while also satisfying security and procurement requirements that are often mandatory during vendor evaluation. Oolvay ships with support for enterprise identity providers, so administrators can connect a company’s OIDC or SAML provider and let employees sign in with their existing corporate credentials instead of a separate password.
This implementation is built on Better Auth’s SSO plugin and supports both major classes of identity providers:
Oolvay uses a single email-first sign-in flow for SSO and magic-link authentication. The LoginForm component does not look up the domain itself. It attempts SSO first and reacts to the result.
0 / 2,000 characters
const { error: ssoError } = await authClient.signIn.sso({ email, callbackURL })
if (!ssoError) {
// SSO matched, Better Auth is handling the redirect
return
}
if (ssoError.status !== 404) {
handleError(ssoError)
return
}
// No SSO provider for this domain, fall back to magic link
const { error } = await signIn.magicLink({ email, callbackURL })A 404 from signIn.sso() is read as no provider configured for this domain, and the form falls back to a magic-link email. Any other error from signIn.sso() is shown to the user directly and does not fall back. This matters operationally. If a provider exists for a domain but is in the Misconfigured state described under Provider Type Is Derived, Not Stored, affected users see an error on the login form rather than silently receiving a magic link instead.
A provider is identified by its domain field. When a user submits an email address whose domain matches a configured provider, Better Auth redirects them into that provider’s OIDC or SAML flow instead of returning a 404. This means domain is not a label. It is the lookup key. A provider configured with domain set to acme.com will intercept sign-in attempts for any @acme.com email address.
Social login, Google and GitHub in the current configuration, is a separate, parallel path and is not part of this fallback chain. Those providers render as their own buttons below the email field, separated by an "Or continue with" divider, and are only triggered by an explicit click.
Providers are added through the Add Provider dialog on the Enterprise SSO admin page, backed by the createSSOProvider server action.
An administrator opens /admin/enterprise-sso and selects Add Provider.
This opens AddProviderDialog, a client component built with
react-hook-form and a Zod resolver.
The administrator chooses a provider type, OIDC or SAML. This toggles which fields are required and rendered, but it is a form-only distinction. See How provider matching works for what is actually persisted.
On submit, ssoProviderSchema validates the form with Zod’s
superRefine(), enforcing that OIDC submissions include a client ID and
secret, and SAML submissions include an entry point and certificate.
createSSOProvider() runs guardAction() with a write type, then checks
isAdmin(user.role). Both checks must pass before the server action
proceeds.
The action calls auth.api.registerSSOProvider(), shaping the payload based
on provider type. SAML submissions automatically receive a generated
callback URL built from BETTER_AUTH_URL and the provider ID.
On success, the dialog closes and router.refresh() re-fetches the
providers list on the server. On failure, the action logs the error, reports
it to Sentry with an admin and createSSOProvider fingerprint, and
returns a message rendered as a toast.
| Field | OIDC | SAML | Notes |
|---|---|---|---|
| Provider | Required | Required | Internal provider ID, 2 to 100 characters |
| Domain | Required | Required | Must match a valid domain pattern. This is the sign-in lookup key |
| Issuer | Required | Required | Must be a valid URL |
| Client ID | Required | Not used | |
| Client Secret | Required | Not used | |
| Discovery URL | Optional | Not used | OIDC discovery endpoint |
| Entry Point | Not used | Required | Must be a valid URL |
| Certificate | Not used | Required | X.509 certificate, pasted as plain text |
Validation lives entirely in ssoProviderSchema, found in lib/validations/sso-provider-schema.ts. The base schema validates shared fields unconditionally. superRefine() then adds conditional checks for whichever provider type was selected, attaching errors to the specific field that failed.
| Field | Value |
|---|---|
| Provider Type | oidc |
| Provider | azure-ad |
| Domain | acme.com |
| Issuer | https://login.microsoftonline.com/{tenant-id}/v2.0 |
| Client ID | from identity provider |
| Client Secret | from identity provider |
| Discovery URL | https://login.microsoftonline.com/{tenant-id}/v2.0/.well-known/openid-configuration |
createSSOProvider() maps this directly onto oidcConfig.
oidcConfig: {
clientId: input.clientId ?? "",
clientSecret: input.clientSecret ?? "",
discoveryEndpoint: input.discoveryUrl,
},If discoveryUrl is left blank, discoveryEndpoint is passed as undefined. Confirm with whoever owns the identity provider whether their OIDC integration requires an explicit discovery endpoint or can resolve one from the issuer alone. This varies by provider and is not something the form enforces.
| Field | Value |
|---|---|
| Provider Type | saml |
| Provider | acme-saml |
| Domain | acme.com |
| Issuer | https://sso.acme.com |
| Entry Point | https://sso.acme.com/login |
The certificate field takes the full X.509 certificate text, pasted as is.
-----BEGIN CERTIFICATE-----
...
-----END CERTIFICATE-----createSSOProvider() maps this onto samlConfig and generates the callback URL itself. It is not a form field.
samlConfig: {
entryPoint: input.entryPoint ?? "",
cert: input.certificate ?? "",
callbackUrl: `${env.BETTER_AUTH_URL}/api/auth/sso/saml2/sp/acs/${input.provider}`,
spMetadata: {},
},For the example above, with BETTER_AUTH_URL set to https://app.acme.com, the generated ACS URL is the following.
https://app.acme.com/api/auth/sso/saml2/sp/acs/acme-samlThis exact URL must be registered as the ACS endpoint on the identity provider’s side before SAML sign-in will work.
Removal is irreversible and immediately affects sign-in for any user authenticating through that provider.
An administrator clicks the trash icon on a provider row in
ProvidersTable. This is a DestructiveActionButton with
requireAreYouSure enabled, so a confirmation step is mandatory.
The confirmation copy is generated per row. If userCount is greater than
zero, the dialog states exactly how many users authenticate through that
provider and warns that they will no longer be able to sign in. If
userCount is zero, the dialog states that no users are currently affected.
On confirmation, deleteSSOProvider(providerId) runs the same guardAction
and isAdmin checks as creation, then calls auth.api.deleteSSOProvider().
The action calls revalidatePath() on the enterprise SSO path on success,
and the table component additionally calls router.refresh() once the
action resolves.
Deletion does not migrate or reassign affected users. Anyone whose only authentication path was the deleted provider will need an alternative sign-in method, such as a password reset, before they can access their account again.
Provider configuration is stored in the sso_provider table, defined in db/schemas/auth-schema.ts. The admin page does not query this table directly for display. It joins against account to compute how many users are linked to each provider.
const providers = await db
.select({
id: ssoProvider.id,
providerId: ssoProvider.providerId,
domain: ssoProvider.domain,
issuer: ssoProvider.issuer,
oidcConfig: ssoProvider.oidcConfig,
samlConfig: ssoProvider.samlConfig,
userCount: count(sql`distinct ${account.userId}`),
})
.from(ssoProvider)
.leftJoin(account, eq(account.providerId, ssoProvider.providerId))
.groupBy(ssoProvider.id)The left join means a provider with zero linked accounts still appears in the result set, with userCount equal to zero rather than being excluded.
ProvidersTable does not read a providerType column to decide whether a row is OIDC or SAML. It inspects which config object is present.
{
provider.oidcConfig ? "OIDC" : provider.samlConfig ? "SAML" : "Unknown"
}The same fields drive the status badge. A row with either config present renders as Active. A row with neither renders as Misconfigured. In practice, every provider created through createSSOProvider() populates exactly one of the two, so the Misconfigured state should only appear if a row was modified outside the normal creation path.
The page itself enforces admin access before rendering.
const session = await getServerSession()
const user = session?.user
if (!session || !user) {
redirect("/login")
}
if (!isAdmin(user.role as Role)) {
unauthorized()
}Both server actions repeat this check independently. createSSOProvider() and deleteSSOProvider() each call guardAction() and isAdmin() before touching auth.api. This is intentional duplication. The page check controls what an administrator sees. The action checks control what a request is allowed to do, regardless of which client invoked it.
Both server actions follow the same failure pattern.
| Step | Behavior |
|---|---|
| Logging | actionLogger() records the failure with relevant identifiers, such as provider, domain, or providerId |
| Sentry | Sentry.withScope() sets a fingerprint of admin and the action name, tags the action and a server action type, and attaches a request ID tag when available |
| Context | scope.setContext() attaches provider-specific details so the Sentry issue is actionable without cross-referencing logs |
| Response | The action returns a failure object whose error message comes from the caught value when it is an Error instance, or a generic fallback string otherwise |
The client side never sees a thrown exception. AddProviderDialog and the delete action inside ProvidersTable both check the result’s success flag and surface the error through a toast.error() call.
Enterprise SSO itself introduces no new environment variables. It depends on values already required for Better Auth.
| Variable | Used For |
|---|---|
BETTER_AUTH_URL | Constructs the SAML ACS callback URL during provider creation |
BETTER_AUTH_SECRET | Required by Better Auth generally, not SSO-specific |
If a SAML identity provider rejects the generated callback URL, confirm BETTER_AUTH_URL in env.ts matches the externally reachable origin of the deployment, not a local or internal one.
domain is checked against a pattern requiring a dot and a two-letter or longer suffix, defined in ssoProviderSchema. This rejects values with a leading protocol such as https://acme.com, a trailing slash, or a bare hostname with no dot such as localhost. Enter the domain only, for example acme.com.
superRefine() treats a field containing only whitespace as empty, since it trims the value before comparing it to an empty string. Confirm the field was not populated by a password manager or copy-paste with leading or trailing whitespace.
This points at the domain lookup, not the provider’s own configuration. Recheck two things in order.
domain on the provider exactly. domain is the lookup key Better Auth uses, not a display label. See How provider matching works.ProvidersTable, not Misconfigured. A Misconfigured row has neither oidcConfig nor samlConfig set and cannot complete a sign-in regardless of domain.Confirm three things against what createSSOProvider() actually sent, rather than against generic SAML defaults.
BETTER_AUTH_URL reflects the production origin, not a local development URL left over from testing the integration.The action surfaces a useful message only when the caught value is an Error instance, falling back to a generic string otherwise. Check Sentry first. The fingerprint and attached context will usually carry more detail than what reaches the toast. See Error Handling and Observability.
userCount reflects linked account rows at page load time. It is not live and will not update until the page is refreshed.providerType selection only affects which fields are rendered and validated client-side. The persisted record is typed by which config object Better Auth populates, not by a stored field. See Provider type is derived, not stored.