# Single Sign-On (SSO) (/docs/plugins/sso)

Integrate Single Sign-On (SSO) with your application.



`OIDC` `OAuth2` `SSO` `SAML`

Single Sign-On (SSO) allows users to authenticate with multiple applications using a single set of credentials. This plugin supports OpenID Connect (OIDC), OAuth2 providers, and SAML 2.0.

<Callout>
  Need self-service SSO where your customers can configure their own SSO connections? [Contact us for enterprise](/enterprise).
</Callout>

## Installation [#installation]

<Steps>
  <Step>
    ### Install the plugin [#install-the-plugin]

    <CodeBlockTabs defaultValue="npm" groupId="persist-install">
      <CodeBlockTabsList>
        <CodeBlockTabsTrigger value="npm">
          npm
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="pnpm">
          pnpm
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="yarn">
          yarn
        </CodeBlockTabsTrigger>

        <CodeBlockTabsTrigger value="bun">
          bun
        </CodeBlockTabsTrigger>
      </CodeBlockTabsList>

      <CodeBlockTab value="npm">
        ```bash
        npm install @better-auth/sso
        ```
      </CodeBlockTab>

      <CodeBlockTab value="pnpm">
        ```bash
        pnpm add @better-auth/sso
        ```
      </CodeBlockTab>

      <CodeBlockTab value="yarn">
        ```bash
        yarn add @better-auth/sso
        ```
      </CodeBlockTab>

      <CodeBlockTab value="bun">
        ```bash
        bun add @better-auth/sso
        ```
      </CodeBlockTab>
    </CodeBlockTabs>
  </Step>

  <Step>
    ### Add Plugin to the server [#add-plugin-to-the-server]

    ```ts title="auth.ts"
    import { betterAuth } from "better-auth"
    import { sso } from "@better-auth/sso"; // [!code highlight]

    const auth = betterAuth({
        plugins: [
            sso() // [!code highlight]
        ]
    })
    ```
  </Step>

  <Step>
    ### Migrate the database [#migrate-the-database]

    Run the migration or generate the schema to add the necessary fields and tables to the database.

    <Tabs items="[&#x22;migrate&#x22;, &#x22;generate&#x22;]">
      <Tab value="migrate">
        <CodeBlockTabs defaultValue="npm" groupId="persist-install">
          <CodeBlockTabsList>
            <CodeBlockTabsTrigger value="npm">
              npm
            </CodeBlockTabsTrigger>

            <CodeBlockTabsTrigger value="pnpm">
              pnpm
            </CodeBlockTabsTrigger>

            <CodeBlockTabsTrigger value="yarn">
              yarn
            </CodeBlockTabsTrigger>

            <CodeBlockTabsTrigger value="bun">
              bun
            </CodeBlockTabsTrigger>
          </CodeBlockTabsList>

          <CodeBlockTab value="npm">
            ```bash
            npx auth migrate
            ```
          </CodeBlockTab>

          <CodeBlockTab value="pnpm">
            ```bash
            pnpm dlx auth migrate
            ```
          </CodeBlockTab>

          <CodeBlockTab value="yarn">
            ```bash
            yarn dlx auth migrate
            ```
          </CodeBlockTab>

          <CodeBlockTab value="bun">
            ```bash
            bun x auth migrate
            ```
          </CodeBlockTab>
        </CodeBlockTabs>
      </Tab>

      <Tab value="generate">
        <CodeBlockTabs defaultValue="npm" groupId="persist-install">
          <CodeBlockTabsList>
            <CodeBlockTabsTrigger value="npm">
              npm
            </CodeBlockTabsTrigger>

            <CodeBlockTabsTrigger value="pnpm">
              pnpm
            </CodeBlockTabsTrigger>

            <CodeBlockTabsTrigger value="yarn">
              yarn
            </CodeBlockTabsTrigger>

            <CodeBlockTabsTrigger value="bun">
              bun
            </CodeBlockTabsTrigger>
          </CodeBlockTabsList>

          <CodeBlockTab value="npm">
            ```bash
            npx auth generate
            ```
          </CodeBlockTab>

          <CodeBlockTab value="pnpm">
            ```bash
            pnpm dlx auth generate
            ```
          </CodeBlockTab>

          <CodeBlockTab value="yarn">
            ```bash
            yarn dlx auth generate
            ```
          </CodeBlockTab>

          <CodeBlockTab value="bun">
            ```bash
            bun x auth generate
            ```
          </CodeBlockTab>
        </CodeBlockTabs>
      </Tab>
    </Tabs>

    See the [Schema](#schema) section to add the fields manually.
  </Step>

  <Step>
    ### Add the client plugin [#add-the-client-plugin]

    ```ts title="auth-client.ts"
    import { createAuthClient } from "better-auth/client"
    import { ssoClient } from "@better-auth/sso/client" // [!code highlight]

    const authClient = createAuthClient({
        plugins: [
            ssoClient() // [!code highlight]
        ]
    })
    ```
  </Step>
</Steps>

## Usage [#usage]

### Register an OIDC Provider [#register-an-oidc-provider]

To register an OIDC provider, use the `registerSSOProvider` endpoint and provide the necessary configuration details for the provider.

A redirect URL will be automatically generated using the provider ID. For instance, if the provider ID is `hydra`, the redirect URL would be `{baseURL}/api/auth/sso/callback/hydra`. Note that `/api/auth` may vary depending on your base path configuration.

<Callout type="info">
  If you need all providers to share a single callback URL (e.g., when migrating from another auth provider), see [Shared Redirect URI](#shared-redirect-uri).
</Callout>

<Callout type="info">
  When you register an OIDC provider, Better Auth automatically fetches and validates the IdP's [OIDC discovery document](#oidc-discovery). Most endpoint fields are optional — see [OIDC Discovery](#oidc-discovery) for details on auto-discovered fields and possible registration errors.
</Callout>

Better Auth verifies the OIDC issuer and `sub` claim before resolving an account. providerId identifies the configured connection; accountId is the provider subject; issuer stores the identity namespace—verified authority under issuer strategy, deterministic provider namespace under provider-id strategy. With explicit `account.identityStrategy: "provider-id"`, protocol verification is unchanged, but the persisted issuer becomes the deterministic `local:oauth:<encoded providerId>` namespace. Explicit `"issuer"` persists the verified authority. Omitting the option uses issuer identity only as v1.7 compatibility behavior; newly generated configurations select provider-id explicitly. The `mapping` option maps profile fields such as email, name, and image, but it cannot replace the account subject. Under issuer-scoped identity, provider aliases that return the same issuer and subject resolve to one external identity; this deduplication does not create independent grant or provider lifecycle records for each alias.

#### Example [#example]

<Tabs items="[&#x22;client&#x22;, &#x22;server&#x22;]">
  <Tab value="client">
    ```ts title="register-oidc-provider.ts"
    import { authClient } from "@/lib/auth-client";

    // Register with OIDC configuration
    await authClient.sso.register({
        providerId: "example-provider",
        issuer: "https://idp.example.com",
        domain: "example.com",
        oidcConfig: {
            clientId: "client-id",
            clientSecret: "client-secret",
            authorizationEndpoint: "https://idp.example.com/authorize",
            tokenEndpoint: "https://idp.example.com/token",
            jwksEndpoint: "https://idp.example.com/jwks",
            discoveryEndpoint: "https://idp.example.com/.well-known/openid-configuration",
            scopes: ["openid", "email", "profile"],
            pkce: true,
            mapping: {
                email: "email",
                emailVerified: "email_verified",
                name: "name",
                image: "picture",
                extraFields: {
                    department: "department",
                    role: "role"
                }
            }
        }
    });
    ```
  </Tab>

  <Tab value="server">
    ```ts title="register-oidc-provider.ts"
    const { headers } = await signInWithTestUser();
    await auth.api.registerSSOProvider({
        body: {
            providerId: "example-provider",
            issuer: "https://idp.example.com",
            domain: "example.com",
            oidcConfig: {
                clientId: "your-client-id",
                clientSecret: "your-client-secret",
                authorizationEndpoint: "https://idp.example.com/authorize",
                tokenEndpoint: "https://idp.example.com/token",
                jwksEndpoint: "https://idp.example.com/jwks",
                discoveryEndpoint: "https://idp.example.com/.well-known/openid-configuration",
                scopes: ["openid", "email", "profile"],
                pkce: true,
                mapping: {
                    email: "email",
                    emailVerified: "email_verified",
                    name: "name",
                    image: "picture",
                    extraFields: {
                        department: "department",
                        role: "role"
                    }
                }
            }
        },
        headers,
    });
    ```
  </Tab>
</Tabs>

### OIDC Discovery [#oidc-discovery]

Better Auth automatically fetches and validates the provider's [OpenID Connect Discovery Document](https://openid.net/specs/openid-connect-discovery-1_0.html) from:

```
{issuer}/.well-known/openid-configuration
```

This allows most endpoint-related fields in `oidcConfig` to be **optional** — they will be hydrated automatically from the Identity Provider (IdP).

**Endpoint:** `POST /sso/register`

> **Note:** Minimal OIDC configuration — endpoints are discovered automatically from the issuer.

### Client Side

```ts
const { data, error } = await authClient.sso.register({
    providerId: "okta", // required, Unique identifier for the provider. Must not collide with a configured social provider, an `accountLinking.trustedProviders` entry, or a reserved built-in id (e.g. `credential`). Registration is rejected (422) otherwise, since SSO provider ids share the account-linking provider namespace and a collision could otherwise inherit trust meant for that provider.
    issuer: "https://your-org.okta.com", // required, The OIDC issuer URL. Discovery document is fetched from `{issuer}/.well-known/openid-configuration`
    domain: "yourcompany.com", // required, Bare email domain, or comma-separated bare email domains, for this provider
    oidcConfig: { // required, OIDC configuration (most fields are auto-discovered)
        clientId: "your-client-id", // required, OAuth client ID from your IdP
        clientSecret: "your-client-secret", // required, OAuth client secret from your IdP
    },
});
```

### Server Side

```ts
const data = await auth.api.registerSSOProvider({
    body: {
        providerId: "okta", // required, Unique identifier for the provider. Must not collide with a configured social provider, an `accountLinking.trustedProviders` entry, or a reserved built-in id (e.g. `credential`). Registration is rejected (422) otherwise, since SSO provider ids share the account-linking provider namespace and a collision could otherwise inherit trust meant for that provider.
        issuer: "https://your-org.okta.com", // required, The OIDC issuer URL. Discovery document is fetched from `{issuer}/.well-known/openid-configuration`
        domain: "yourcompany.com", // required, Bare email domain, or comma-separated bare email domains, for this provider
        oidcConfig: { // required, OIDC configuration (most fields are auto-discovered)
            clientId: "your-client-id", // required, OAuth client ID from your IdP
            clientSecret: "your-client-secret", // required, OAuth client secret from your IdP
        },
    },
    // This endpoint requires session cookies.
    headers: await headers(),
});
```

### Type Definition

```ts
type registerSSOProvider = {
    /**
     * Unique identifier for the provider.
     *
     * Must not collide with a configured social provider, an
     * `accountLinking.trustedProviders` entry, or a reserved built-in id
     * (e.g. `credential`). Registration is rejected (422) otherwise, since
     * SSO provider ids share the account-linking provider namespace and a
     * collision could otherwise inherit trust meant for that provider.
     */
    providerId: string = "okta"
    /**
     * The OIDC issuer URL. Discovery document is fetched from `{issuer}/.well-known/openid-configuration`
     */
    issuer: string = "https://your-org.okta.com"
    /**
     * Bare email domain, or comma-separated bare email domains, for this provider
     */
    domain: string = "yourcompany.com"
    /**
     * OIDC configuration (most fields are auto-discovered)
     */
    oidcConfig: {
        /**
         * OAuth client ID from your IdP
         */
        clientId: string = "your-client-id"
        /**
         * OAuth client secret from your IdP
         */
        clientSecret: string = "your-client-secret"
    }
}
```

#### Fields Automatically Discovered [#fields-automatically-discovered]

Better Auth fills in the following fields by reading the IdP's discovery document (if not explicitly provided):

* `authorizationEndpoint`
* `tokenEndpoint`
* `jwksEndpoint`
* `userInfoEndpoint`
* `discoveryEndpoint`
* `tokenEndpointAuthentication` (method for token endpoint client authentication)

Following the spec, our discovery process expects all URLs to be valid and to be absolute urls. Relative paths are also supported and resolved relative to the issuer's base URL preserving the path when available.

Example of relative endpoint and issuer without base path:

* `issuer`: `"https://your-org.okta.com"`
* `token_endpoint`: `"/v1/tokens"`
* normalized `token_endpoint`: `"https://your-org.okta.com/v1/tokens"`

Example of relative endpoint and issuer with base path:

* `issuer`: `"https://your-org.okta.com/v1"`
* `token_endpoint`: `"/tokens"`
* normalized `token_endpoint`: `"https://your-org.okta.com/v1/tokens"`

<Callout type="info">
  If you explicitly set these fields in `oidcConfig`, your values override the discovered ones.
  This is useful when you need to override the IdP's advertised metadata or when using incomplete mock servers.
</Callout>

#### Trusted origins [#trusted-origins]

Both the discovery endpoint as well as any URL resolved through the discovery process are subject to your app's [`trustedOrigins`](/docs/reference/security#trusted-origins) configuration.
Discovery will fail with the `discovery_untrusted_origin` code unless you explicitly update your `trustedOrigins` configuration:

```ts
trustedOrigins: ["https://your-org.okta.com"],
```

If your use-case requires to support multiple arbitrary but known IDPs (e.g Okta), we recommend to:

1. Register a list of well known IDPs ahead of time

```ts
trustedOrigins: [
    "https://your-org.okta.com",
    "https://accounts.google.com",
    "https://login.microsoftonline.com",
    "https://auth0.com",
    "https://idp.example.com"
],
```

2. Or dynamically compute the `trustedOrigins` by specifying a callback function:

```ts
trustedOrigins: async (request) => {
    // request is undefined during initialization and auth.api calls
    if (!request) {
        return ["https://my-frontend.com"];
    }

    // SSO trusted origin list
    if (request.url.endsWith("/sso/register")) {
        const trustedOrigins = await fetchOriginList();
        return trustedOrigins;
    }

    // Your normal origin list for everything else
    return [];
}
```

See the [`trustedOrigins`](/docs/reference/security#trusted-origins) docs for more information.

#### Redirecting OIDC endpoints [#redirecting-oidc-endpoints]

Better Auth does not follow redirects for server-side OIDC requests. Configure the final canonical URL for the discovery, token, userinfo, and JWKS endpoints.

If one of those endpoints returns a `3xx` response, registration or sign-in fails with `oidc_endpoint_redirect`. This keeps endpoint validation tied to the exact URL that Better Auth fetches.

#### Why Discovery Can Fail [#why-discovery-can-fail]

Better Auth validates that the IdP's metadata is correct and complete **before** allowing registration. This prevents subtle runtime failures during sign-in or token validation.

<Callout type="info">
  Better Auth does **not** support implicit-only OIDC flows.
  For this reason, `token_endpoint` and `jwks_uri` are required even though the OIDC spec allows implicit-only providers to omit `token_endpoint`.
</Callout>

#### Discovery Errors [#discovery-errors]

If the Identity Provider is misconfigured or unreachable, registration will fail with a structured error.

| Error Code                      | Meaning                                                                                                                             |
| ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `issuer_mismatch`               | The IdP's discovery document reports a different `issuer` than the one you configured                                               |
| `discovery_incomplete`          | Required fields (`authorization_endpoint`, `token_endpoint`, `jwks_uri`) are missing                                                |
| `discovery_not_found`           | The discovery document endpoint returned 404                                                                                        |
| `discovery_timeout`             | The IdP did not respond within the timeout window (default: 10 seconds)                                                             |
| `discovery_invalid_url`         | The discovery URL is malformed or uses an unsupported protocol                                                                      |
| `discovery_untrusted_origin`    | The discovery URL or one of the URLs discovered as part of this process was not trusted by your app's trusted origins configuration |
| `discovery_invalid_json`        | The discovery response is empty or not valid JSON                                                                                   |
| `oidc_endpoint_redirect`        | The discovery, token, userinfo, or JWKS endpoint returned a redirect; configure the final endpoint URL instead                      |
| `unsupported_token_auth_method` | The IdP only supports token auth methods that Better Auth doesn't support                                                           |

**Supported token auth methods:**

* `client_secret_basic`
* `client_secret_post`

<Callout type="warning">
  If your IdP advertises only unsupported methods (e.g., `private_key_jwt`, `tls_client_auth`, or `"none"` for public clients), you can explicitly override the method:

  ```ts
  oidcConfig: {
      clientId: "your-client-id",
      clientSecret: "your-client-secret",
      tokenEndpointAuthentication: "client_secret_basic", // Override discovery
  }
  ```

  This is especially common with mock OIDC servers or development IdPs that only advertise `"none"` as the supported method.
</Callout>

#### Summary [#summary]

* Better Auth automatically performs OIDC discovery at registration time
* Most endpoint settings in `oidcConfig` become optional
* Explicit user configuration always overrides discovery
* Registration fails fast if the IdP is misconfigured
* Discovery errors are structured and well-defined
* Public-client IdPs or mock servers may require overriding `tokenEndpointAuthentication`

### Resolve SSO Users [#resolve-sso-users]

Use `resolveUser` when an OIDC or SAML identity must be mapped to an existing Better Auth user instead of relying on email matching. The resolver runs on every SSO sign-in after the protocol identity has been verified.

```ts title="auth.ts"
import { betterAuth } from "better-auth";
import { sso } from "@better-auth/sso";
import {
    findProvisionedUserByAccountKey,
    validateOIDCClaims,
    validateSAMLAttributes,
} from "@/lib/provisioning";

const auth = betterAuth({
    plugins: [
        sso({
            resolveUser: async (input, context) => {
                if (input.providerId !== "workforce") {
                    return { action: "continue" };
                }

                if (input.protocol === "oidc") {
                    await validateOIDCClaims(input.verifiedIdTokenClaims);
                } else {
                    await validateSAMLAttributes(input.providerAttributes);
                }

                const provisionedUser = await findProvisionedUserByAccountKey({
                    issuer: input.accountKey.issuer,
                    subject: input.accountKey.accountId,
                    database: context.database,
                });

                if (!provisionedUser) {
                    return {
                        action: "reject",
                        code: "PROVISIONED_USER_NOT_FOUND",
                    };
                }

                return {
                    action: "link",
                    userId: provisionedUser.userId,
                    profile: "preserve",
                };
            },
        }),
    ],
});
```

`accountKey` always contains the verified protocol authority and subject: the validated OIDC ID Token's exact `iss` and `sub` claims, or the verified SAML identity provider entity ID and signed `NameID`. It does not change with `account.identityStrategy`. Under the provider-id strategy, Better Auth persists the same subject with a deterministic provider namespace in `Account.issuer`; that storage choice is separate from the verified issuer exposed to the resolver. Subject and `NameID` matching is case-sensitive.

`providerUser` contains normalized profile fields for either protocol. Profile fields, OIDC UserInfo claims, and SAML attributes may still require application-specific validation before they are used for authorization. `providerReference` is an opaque reference to the provider configuration accepted for this authentication flow. It detects provider replacement or authentication-configuration changes during the flow; do not persist it as a tenant or user binding.

`findProvisionedUserByAccountKey` is application-owned in this example. It should query the application's provisioning registry by the verified protocol authority and subject through the transaction-scoped `database` adapter; it does not query Better Auth's persisted `Account.issuer` namespace. Do not fall back to matching an email address when the exact provisioned subject is absent.

The resolver returns one of three decisions:

* `{ action: "continue" }` continues the normal SSO sign-in behavior.
* `{ action: "link", userId, profile }` links the exact SSO account to that user. Use `profile: "preserve"` to keep the local profile or `profile: "update"` to update it from the provider profile.
* `{ action: "reject", code, message? }` rejects the sign-in with an application-defined error.

The callback receives the active transaction adapter as `context.database`. Keep resolver work database-local and idempotent because a fresh SSO authentication attempt may invoke it again; an already consumed OIDC callback or SAML assertion is not replayed. Better Auth requires native database transactions and transaction async-context support before calling the resolver. If secondary storage is configured, set `session.storeSessionInDatabase: true` and leave `session.preserveSessionInDatabase` unset or `false` so the database remains the session fallback.

Database writes made through the transaction adapter during resolution, account linking, profile updates, and database session creation commit or roll back together. Network calls and other external side effects performed by the resolver or database hooks cannot be rolled back. Account and session cookies are published only after commit. Secondary-storage session mirroring and other deferred hooks run after commit; failures are logged without undoing the committed database session or suppressing its cookies.

### Register a SAML Provider [#register-a-saml-provider]

To register a SAML provider, use the `registerSSOProvider` endpoint with SAML configuration details. The provider will act as a Service Provider (SP) and integrate with your Identity Provider (IdP).

Better Auth identifies a SAML account by the IdP entity ID and the signed `NameID`. The `issuer` field identifies your service provider, while the IdP entity ID comes from `idpMetadata.metadata` or an explicit `idpMetadata.entityID` in a manual configuration. Attribute mappings can populate profile fields, but they cannot replace the signed `NameID` account subject.

Applications that need to pin the IdP authority while storing a SAML connection can derive it through the same metadata parser used by SAML authentication:

```ts title="saml-authority.ts"
import { deriveSAMLIdentityProviderEntityID } from "@better-auth/sso";

const identityProviderEntityID =
    deriveSAMLIdentityProviderEntityID(samlConfig);
```

<Tabs items="[&#x22;client&#x22;, &#x22;server&#x22;]">
  <Tab value="client">
    ```ts title="register-saml-provider.ts"
    import { authClient } from "@/lib/auth-client";

    await authClient.sso.register({
        providerId: "saml-provider",
        issuer: "https://yourapp.com/saml",
        domain: "example.com",
        samlConfig: {
            entryPoint: "https://idp.example.com/sso",
            audience: "https://yourapp.com",
            wantAssertionsSigned: true,
            signatureAlgorithm: "sha256",
            digestAlgorithm: "sha256",
            identifierFormat: "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
            idpMetadata: {
                metadata: "<!-- IdP Metadata XML -->",
                privateKey: "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----",
                privateKeyPass: "your-private-key-password",
                isAssertionEncrypted: true,
                encPrivateKey: "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----",
                encPrivateKeyPass: "your-encryption-key-password"
            },
            spMetadata: {
                metadata: "<!-- SP Metadata XML -->",
                binding: "post",
                privateKey: "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----",
                privateKeyPass: "your-sp-private-key-password",
                isAssertionEncrypted: true,
                encPrivateKey: "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----",
                encPrivateKeyPass: "your-sp-encryption-key-password"
            },
            mapping: {
                email: "email",
                name: "displayName",
                firstName: "givenName",
                lastName: "surname",
                emailVerified: "email_verified",
                extraFields: {
                    department: "department",
                    role: "role"
                }
            }
        }
    });
    ```
  </Tab>

  <Tab value="server">
    ```ts title="register-saml-provider.ts"
    const { headers } = await signInWithTestUser();
    await auth.api.registerSSOProvider({
        body: {
            providerId: "saml-provider",
            issuer: "https://yourapp.com/saml",
            domain: "example.com",
            samlConfig: {
                entryPoint: "https://idp.example.com/sso",
                audience: "https://yourapp.com",
                wantAssertionsSigned: true,
                signatureAlgorithm: "sha256",
                digestAlgorithm: "sha256",
                identifierFormat: "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
                idpMetadata: {
                    metadata: "<!-- IdP Metadata XML -->",
                    privateKey: "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----",
                    privateKeyPass: "your-private-key-password",
                    isAssertionEncrypted: true,
                    encPrivateKey: "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----",
                    encPrivateKeyPass: "your-encryption-key-password"
                },
                spMetadata: {
                    metadata: "<!-- SP Metadata XML -->",
                    binding: "post",
                    privateKey: "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----",
                    privateKeyPass: "your-sp-private-key-password",
                    isAssertionEncrypted: true,
                    encPrivateKey: "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----",
                    encPrivateKeyPass: "your-sp-encryption-key-password"
                },
                mapping: {
                    email: "email",
                    name: "displayName",
                    firstName: "givenName",
                    lastName: "surname",
                    emailVerified: "email_verified",
                    extraFields: {
                        department: "department",
                        role: "role"
                    }
                }
            }
        },
        headers,
    });
    ```
  </Tab>
</Tabs>

### IdP-Initiated SSO [#idp-initiated-sso]

For OIDC providers that initiate logins without sending a `state` parameter, set `allowIdpInitiated: true` on the provider's `oidcConfig`. When a stateless callback arrives at `/sso/callback/:providerId`, the IdP-issued code is discarded and a new OAuth flow is started server-side with a fresh `state` and PKCE verifier. CSRF protection remains in effect. This flag defaults to `false`.

<Callout type="warn">
  Set the global `baseURL` option when enabling `allowIdpInitiated` on an OIDC provider. IdP-initiated callbacks carry no initiating request body, so the bounce uses `baseURL` as the post-login destination. If `baseURL` is unset, the bounce fails with `CALLBACK_URL_REQUIRED`.
</Callout>

For IdP-initiated flows (e.g., via Okta dashboard), your framework may require an explicit route handler to manage the redirect if the default handler doesn't support the `GET` request following the SAML POST.

<Tabs items="[&#x22;next-js-app-router&#x22;]">
  <Tab value="next-js-app-router">
    Create this file to prevent 404 errors:

    ```ts title="app/api/auth/sso/saml2/sp/acs/[providerId]/route.ts"
    import { auth } from "@/lib/auth";
    import { NextResponse } from "next/server";

    export async function POST(req: Request) {
        return auth.handler(req);
    }

    export async function GET(req: Request) {
        // Required: IdP-initiated flows redirect to this URL after POST
        return NextResponse.redirect(new URL("/", req.url));
    }
    ```
  </Tab>
</Tabs>

### Get Service Provider Metadata [#get-service-provider-metadata]

For SAML providers, you can retrieve the Service Provider metadata XML that needs to be configured in your Identity Provider:

```ts title="get-sp-metadata.ts"
const response = await auth.api.spMetadata({
    query: {
        providerId: "saml-provider",
        format: "xml" // or "json"
    }
});

const metadataXML = await response.text();
console.log(metadataXML);
```

### Sign In with SSO [#sign-in-with-sso]

To sign in with an SSO provider, you can call `signIn.sso`

You can sign in using the email with domain matching:

```ts title="sign-in.ts"
import { authClient } from "@/lib/auth-client"

const res = await authClient.signIn.sso({
    email: "user@example.com",
    callbackURL: "/dashboard",
});
```

or you can specify the domain:

```ts title="sign-in-domain.ts"
import { authClient } from "@/lib/auth-client"

const res = await authClient.signIn.sso({
    domain: "example.com",
    callbackURL: "/dashboard",
});
```

You can also sign in using the organization slug if a provider is associated with an organization:

```ts title="sign-in-org.ts"
import { authClient } from "@/lib/auth-client"

const res = await authClient.signIn.sso({
    organizationSlug: "example-org",
    callbackURL: "/dashboard",
});
```

Alternatively, you can sign in using the provider's ID:

```ts title="sign-in-provider-id.ts"
import { authClient } from "@/lib/auth-client"

const res = await authClient.signIn.sso({
    providerId: "example-provider-id",
    callbackURL: "/dashboard",
});
```

Optionally, you can pass a login hint (for example, an email address or another identifier) to prefill or direct the identity provider:

```ts title="sign-in-with-login-hint.ts"
import { authClient } from "@/lib/auth-client"

const res = await authClient.signIn.sso({
    providerId: "example-provider-id",
    loginHint: "user@example.com",
    callbackURL: "/dashboard",
});
```

To use the server API you can use `signInSSO`

```ts title="sign-in-org.ts"
import { authClient } from "@/lib/auth-client"

const res = await auth.api.signInSSO({
    body: {
        organizationSlug: "example-org",
        callbackURL: "/dashboard",
    }
});
```

#### Full method [#full-method]

**Endpoint:** `POST /sign-in/sso`

### Client Side

```ts
const { data, error } = await authClient.signIn.sso({
    email: "john@example.com", // The email address to sign in with. This is used to identify the issuer to sign in with. It's optional if the issuer is provided.
    organizationSlug: "example-org", // The slug of the organization to sign in with.
    providerId: "example-provider", // The ID of the provider to sign in with. This can be provided instead of email or issuer.
    domain: "example.com", // The domain of the provider.
    callbackURL: "https://example.com/callback", // required, The URL to redirect to after login.
    errorCallbackURL: "https://example.com/callback", // The URL to redirect to after login.
    newUserCallbackURL: "https://example.com/new-user", // The URL to redirect to after login if the user is new.
    scopes: ["openid", "email", "profile", "offline_access"], // Scopes to request from the provider.
    loginHint: "user@example.com", // Login hint to send to the identity provider (e.g., email or identifier).
    requestSignUp: true, // Explicitly request sign-up. Useful when disableImplicitSignUp is true for this provider.
});
```

### Server Side

```ts
const data = await auth.api.signInSSO({
    body: {
        email: "john@example.com", // The email address to sign in with. This is used to identify the issuer to sign in with. It's optional if the issuer is provided.
        organizationSlug: "example-org", // The slug of the organization to sign in with.
        providerId: "example-provider", // The ID of the provider to sign in with. This can be provided instead of email or issuer.
        domain: "example.com", // The domain of the provider.
        callbackURL: "https://example.com/callback", // required, The URL to redirect to after login.
        errorCallbackURL: "https://example.com/callback", // The URL to redirect to after login.
        newUserCallbackURL: "https://example.com/new-user", // The URL to redirect to after login if the user is new.
        scopes: ["openid", "email", "profile", "offline_access"], // Scopes to request from the provider.
        loginHint: "user@example.com", // Login hint to send to the identity provider (e.g., email or identifier).
        requestSignUp: true, // Explicitly request sign-up. Useful when disableImplicitSignUp is true for this provider.
    },
});
```

### Type Definition

```ts
type signInSSO = {
    /**
     * The email address to sign in with. This is used to identify the issuer to sign in with. It's optional if the issuer is provided. 
     */
    email?: string = "john@example.com"
    /**
     * The slug of the organization to sign in with. 
     */
    organizationSlug?: string = "example-org"
    /**
     * The ID of the provider to sign in with. This can be provided instead of email or issuer. 
     */
    providerId?: string = "example-provider"
    /**
     * The domain of the provider. 
     */
    domain?: string = "example.com"
    /**
     * The URL to redirect to after login. 
     */
    callbackURL: string = "https://example.com/callback"
    /**
     * The URL to redirect to after login. 
     */
    errorCallbackURL?: string = "https://example.com/callback"
    /**
     * The URL to redirect to after login if the user is new. 
     */
    newUserCallbackURL?: string = "https://example.com/new-user"
    /**
     * Scopes to request from the provider. 
     */
    scopes?: string[] = ["openid", "email", "profile", "offline_access"]
    /**
     * Login hint to send to the identity provider (e.g., email or identifier). 
     */
    loginHint?: string = "user@example.com"
    /**
     * Explicitly request sign-up. Useful when disableImplicitSignUp is true for this provider. 
     */
    requestSignUp?: boolean = true
}
```

Note: If email is provided and loginHint is not specified, email will be sent as the login\_hint to OIDC providers automatically. SAML flows do not support login\_hint.

When a user is authenticated, if the user does not exist, the user will be provisioned using the `provisionUser` function. By default, `provisionUser` only runs when a new user is registered. If you want to run it on every login (e.g. to sync upstream identity provider profile changes), set `provisionUserOnEveryLogin` to `true`. If the organization provisioning is enabled and a provider is associated with an organization, the user will be added to the organization.

```ts title="auth.ts"
import { betterAuth } from "better-auth";
import { sso } from "@better-auth/sso";

const auth = betterAuth({
    plugins: [
        sso({
            provisionUser: async (user) => {
                // provision user
            },
            provisionUserOnEveryLogin: true, // optional, default: false
            organizationProvisioning: {
                disabled: false,
                defaultRole: "member",
                getRole: async (user) => {
                    // get role if needed
                },
            },
        }),
    ],
});
```

## Provisioning [#provisioning]

The SSO plugin provides powerful provisioning capabilities to automatically set up users and manage their organization memberships when they sign in through SSO providers.

### User Provisioning [#user-provisioning]

User provisioning allows you to run custom logic when a user signs in through an SSO provider. By default, `provisionUser` only runs for new users (on registration). To run it on every login, set `provisionUserOnEveryLogin` to `true`. This is useful for:

* Setting up user profiles with additional data from the SSO provider
* Synchronizing user attributes with external systems
* Creating user-specific resources
* Logging SSO sign-ins
* Updating user information from the SSO provider

```ts title="auth.ts"
import { betterAuth } from "better-auth";
import { sso } from "@better-auth/sso";

const auth = betterAuth({
    plugins: [
        sso({
            provisionUser: async ({ user, userInfo, token, provider }) => {
                // Update user profile with SSO data
                await updateUserProfile(user.id, {
                    department: userInfo.attributes?.department,
                    jobTitle: userInfo.attributes?.jobTitle,
                    manager: userInfo.attributes?.manager,
                    lastSSOLogin: new Date(),
                });

                // Create user-specific resources
                await createUserWorkspace(user.id);

                // Sync with external systems
                await syncUserWithCRM(user.id, userInfo);

                // Log the SSO sign-in
                await auditLog.create({
                    userId: user.id,
                    action: 'sso_signin',
                    provider: provider.providerId,
                    metadata: {
                        email: userInfo.email,
                        ssoProvider: provider.issuer,
                    },
                });
            },
        }),
    ],
});
```

The `provisionUser` function receives:

* **user**: The user object from the database
* **userInfo**: User information from the SSO provider (includes attributes, email, name, etc.)
* **token**: OAuth2 tokens (for OIDC providers) - may be undefined for SAML
* **provider**: The SSO provider configuration

### Organization Provisioning [#organization-provisioning]

Organization provisioning automatically manages user memberships in organizations when SSO providers are linked to specific organizations. This is particularly useful for:

* Enterprise SSO where each company/domain maps to an organization
* Automatic role assignment based on SSO attributes
* Managing team memberships through SSO

#### Basic Organization Provisioning [#basic-organization-provisioning]

```ts title="auth.ts"
import { betterAuth } from "better-auth";
import { sso } from "@better-auth/sso";

const auth = betterAuth({
    plugins: [
        sso({
            organizationProvisioning: {
                disabled: false,           // Enable org provisioning
                defaultRole: "member",     // Default role for new members
            },
        }),
    ],
});
```

#### Advanced Organization Provisioning with Custom Roles [#advanced-organization-provisioning-with-custom-roles]

```ts title="auth.ts"
import { betterAuth } from "better-auth";
import { sso } from "@better-auth/sso";

const auth = betterAuth({
    plugins: [
        sso({
            organizationProvisioning: {
                disabled: false,
                defaultRole: "member",
                getRole: async ({ user, userInfo, provider }) => {
                    // Assign roles based on SSO attributes
                    const department = userInfo.attributes?.department;
                    const jobTitle = userInfo.attributes?.jobTitle;
                    
                    // Admins based on job title
                    if (jobTitle?.toLowerCase().includes('manager') || 
                        jobTitle?.toLowerCase().includes('director') ||
                        jobTitle?.toLowerCase().includes('vp')) {
                        return "admin";
                    }
                    
                    // Special roles for IT department
                    if (department?.toLowerCase() === 'it') {
                        return "admin";
                    }
                    
                    // Default to member for everyone else
                    return "member";
                },
            },
        }),
    ],
});
```

#### Linking SSO Providers to Organizations [#linking-sso-providers-to-organizations]

When registering an SSO provider, you can link it to a specific organization:

<Callout type="info">
  When the `organization` plugin is enabled and `organizationId` is supplied, the caller must be an organization `owner` or `admin`. Regular members receive a `403 FORBIDDEN`. SSO deployments without the `organization` plugin keep the prior behavior (membership lookup only).
</Callout>

```ts title="register-org-provider.ts"
import { auth } from "@/lib/auth"

await auth.api.registerSSOProvider({
    body: {
        providerId: "acme-corp-saml",
        issuer: "https://yourapp.com/saml/acme-corp",
        domain: "acmecorp.com",
        organizationId: "org_acme_corp_id", // Link to organization
        samlConfig: {
            // SAML configuration...
        },
    },
    headers: await headers() // headers containing the user's session token
});
```

Users who sign in through this provider are automatically added to the "Acme
Corp" organization because the provider is explicitly linked to that
organization. Domain-derived assignment from another sign-in method requires
domain verification, a verified stored user email, and one unambiguous
organization match.

<Callout type="info">
  Automatic SSO provisioning does not accept or cancel pending invitations. An
  invited user must complete the existing invitation flow before Better Auth
  creates an automatic membership for the same organization.
</Callout>

#### Self-Service SSO Dashboard [#self-service-sso-dashboard]

If you're using [Better Auth Infrastructure](https://dash.better-auth.com/sign-in), you get access to a self-service SSO dashboard that simplifies onboarding enterprise customers. Instead of manually exchanging SAML metadata and certificates, organization admins can generate a shareable onboarding link that guides enterprise IT teams through configuring their identity provider.

The dashboard is available at:

```
https://dash.better-auth.com/[project]/organization/[orgId]/enterprise
```

From the dashboard you can:

* **Generate onboarding links** for enterprise customers to self-configure their SAML provider
* **Monitor SSO connection status** for each organization
* **Manage provider configurations** without writing code

This eliminates the back-and-forth typically required when setting up enterprise SSO, reducing onboarding time from days to minutes.

#### Multiple Organizations Example [#multiple-organizations-example]

You can set up multiple SSO providers for different organizations:

```ts title="multi-org-setup.ts"
import { auth } from "@/lib/auth"

// Acme Corp SAML provider
await auth.api.registerSSOProvider({
    body: {
        providerId: "acme-corp",
        issuer: "https://yourapp.com/saml/acme-corp",
        domain: "acmecorp.com",
        organizationId: "org_acme_id",
        samlConfig: { /* ... */ },
    },
    headers,
});

// TechStart OIDC provider
await auth.api.registerSSOProvider({
    body: {
        providerId: "techstart-google",
        issuer: "https://accounts.google.com",
        domain: "techstart.io",
        organizationId: "org_techstart_id",
        oidcConfig: { /* ... */ },
    },
    headers,
});
```

#### Organization Provisioning Flow [#organization-provisioning-flow]

1. **User signs in** through an SSO provider linked to an organization
2. **User is authenticated** and either found or created in the database
3. **Organization membership is checked** - if the user isn't already a member of the linked organization
4. **Role is determined** using either the `defaultRole` or `getRole` function
5. **User is added** to the organization with the determined role
6. **User provisioning runs** (if configured) for additional setup

### Provisioning Best Practices [#provisioning-best-practices]

#### 1. Idempotent Operations [#1-idempotent-operations]

If you enable `provisionUserOnEveryLogin`, make sure your provisioning functions can be safely run multiple times:

```ts
provisionUser: async ({ user, userInfo }) => {
    // Check if already provisioned
    const existingProfile = await getUserProfile(user.id);
    if (!existingProfile.ssoProvisioned) {
        await createUserResources(user.id);
        await markAsProvisioned(user.id);
    }
    
    // Always update attributes (they might change)
    await updateUserAttributes(user.id, userInfo.attributes);
},
```

#### 2. Error Handling [#2-error-handling]

Handle errors gracefully to avoid blocking user sign-in:

```ts
provisionUser: async ({ user, userInfo }) => {
    try {
        await syncWithExternalSystem(user, userInfo);
    } catch (error) {
        // Log error but don't throw - user can still sign in
        console.error('Failed to sync user with external system:', error);
        await logProvisioningError(user.id, error);
    }
},
```

#### 3. Conditional Provisioning [#3-conditional-provisioning]

Only run certain provisioning steps when needed:

```ts
organizationProvisioning: {
    disabled: false,
    getRole: async ({ user, userInfo, provider }) => {
        // Only process role assignment for certain providers
        if (provider.providerId.includes('enterprise')) {
            return determineEnterpriseRole(userInfo);
        }
        return "member";
    },
},
```

## SAML Configuration [#saml-configuration]

### Default SSO Provider [#default-sso-provider]

```ts title="auth.ts"
import { betterAuth } from "better-auth";
import { sso } from "@better-auth/sso";

const auth = betterAuth({
    plugins: [
        sso({
            defaultSSO: [
                {
                    providerId: "default-saml", // Provider ID for the default provider
                    domain: "http://your-app.com",
                    samlConfig: {
                        issuer: "https://your-app.com",
                        entryPoint: "https://idp.example.com/sso",
                        cert: "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----",
                        idpMetadata: {
                            entityID: "https://idp.example.com",
                        },
                        spMetadata: {
                            entityID: "http://localhost:3000/api/auth/sso/saml2/sp/metadata",
                            metadata: "<!-- Your SP Metadata XML -->",
                        }
                    }
                }
            ]
        })
    ]
});
```

The defaultSSO provider will be used when:

1. No matching provider is found in the database

This allows you to test SAML authentication without setting up providers in the database. The defaultSSO provider supports all the same configuration options as regular SAML providers.

### Service Provider Configuration [#service-provider-configuration]

When registering a SAML provider, you need to provide Service Provider (SP) metadata configuration:

* **metadata**: XML metadata for the Service Provider
* **binding**: The binding method, typically "post" or "redirect"
* **privateKey**: Private key for signing AuthnRequests
* **privateKeyPass**: Password for the private key
* **isAssertionEncrypted**: Whether assertions should be encrypted
* **encPrivateKey**: Private key for decryption (if encryption is enabled)
* **encPrivateKeyPass**: Password for the encryption private key

### Signed AuthnRequests [#signed-authnrequests]

Some enterprise IdPs (Okta, Azure AD, ADFS) require signed AuthnRequests. Enable this with:

```ts
samlConfig: {
    // ... other config
    authnRequestsSigned: true,
    spMetadata: {
        privateKey: "-----BEGIN RSA PRIVATE KEY-----\n...",
    }
}
```

The SP metadata endpoint will automatically include `AuthnRequestsSigned="true"` when enabled.

### Identity Provider Configuration [#identity-provider-configuration]

You also need to provide Identity Provider (IdP) configuration:

* **metadata**: XML metadata from your Identity Provider
* **privateKey**: Private key for the IdP communication (optional)
* **privateKeyPass**: Password for the IdP private key (if encrypted)
* **isAssertionEncrypted**: Whether assertions from IdP are encrypted
* **encPrivateKey**: Private key for IdP assertion decryption
* **encPrivateKeyPass**: Password for the IdP decryption key

### Signing Certificates and Rotation [#signing-certificates-and-rotation]

When your IdP rotates its signing certificate, list both the old and the new PEM under `idpMetadata.cert` as an array. Better Auth accepts SAML responses signed by either, so users keep signing in while the rotation completes.

```ts
samlConfig: {
    entryPoint: "https://idp.example.com/sso",
    idpMetadata: {
        entityID: "https://idp.example.com",
        singleSignOnService: [
            { Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST", Location: "https://idp.example.com/sso" },
        ],
        cert: [
            "-----BEGIN CERTIFICATE-----\n<current>\n-----END CERTIFICATE-----",
            "-----BEGIN CERTIFICATE-----\n<next>\n-----END CERTIFICATE-----",
        ],
    },
}
```

Both `samlConfig.cert` and `samlConfig.idpMetadata.cert` accept either a single PEM string or an array. When both are set, `idpMetadata.cert` wins.

If your IdP publishes a metadata XML document, pass it as `idpMetadata.metadata` instead and leave `cert` unset. The certificates come from the document and rotation happens when the IdP republishes it. If a config supplies neither `cert` nor `idpMetadata.metadata`, registration fails with `CERT_SOURCE_MISSING`, since samlify has nothing to verify SAML responses against.

Applications can use `guardProviderMutation` to coordinate SSO changes with a paired directory-sync control plane. IdP entity IDs, endpoints, signing certificates, SP entity IDs, NameID formats, POST assertion-consumer URLs, and assertion-signing policy are authentication-boundary changes. If the guard blocks authentication-boundary changes while a directory is paired, unpair the directory, update the IdP trust configuration, and then re-pair it. OIDC client-secret rotation and SAML SP private-key rotation do not change the authentication boundary.

### SAML Attribute Mapping [#saml-attribute-mapping]

Configure how SAML attributes map to user fields:

```ts
mapping: {
    email: "email",         // Default: "email" or "nameID"
    name: "displayName",    // Default: "displayName"
    firstName: "givenName", // Default: "givenName"
    lastName: "surname",    // Default: "surname"
    extraFields: {
        department: "department",
        role: "jobTitle",
        phone: "telephoneNumber"
    }
}
```

## SAML Security [#saml-security]

The SSO plugin includes optional security features to protect against common SAML vulnerabilities.

### AuthnRequest / InResponseTo Validation [#authnrequest--inresponseto-validation]

You can enable InResponseTo validation for SP-initiated SAML flows. When enabled, the plugin tracks AuthnRequest IDs and validates the `InResponseTo` attribute in SAML responses. This prevents:

* **Unsolicited responses**: Responses not triggered by a legitimate login request
* **Replay attacks**: Reusing old SAML responses
* **Cross-provider injection**: Responses meant for a different provider

<Callout type="info">
  This feature is **opt-in** to ensure backward compatibility. Enable it explicitly for enhanced security.
</Callout>

#### Enabling Validation (Single Instance) [#enabling-validation-single-instance]

For single-instance deployments, enable validation with the built-in in-memory store:

```ts title="auth.ts"
import { betterAuth } from "better-auth";
import { sso } from "@better-auth/sso";

const auth = betterAuth({
    plugins: [
        sso({
            saml: {
                // InResponseTo validation is enabled by default
                enableInResponseToValidation: true,
                // Optionally reject IdP-initiated SSO (stricter security)
                allowIdpInitiated: false,
                // Custom TTL for AuthnRequest validity (default: 5 minutes)
                requestTTL: 10 * 60 * 1000, // 10 minutes
            },
        }),
    ],
});
```

#### Options [#options]

| Option                         | Type      | Default          | Description                                                                                                                              |
| ------------------------------ | --------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `enableInResponseToValidation` | `boolean` | `true`           | Enable InResponseTo validation for SP-initiated flows.                                                                                   |
| `allowIdpInitiated`            | `boolean` | `true`           | Allow IdP-initiated SSO (responses without InResponseTo). Set to `false` for stricter security. Only applies when validation is enabled. |
| `requestTTL`                   | `number`  | `300000` (5 min) | Time-to-live for AuthnRequest records in milliseconds. Requests older than this will be rejected.                                        |

#### Error Handling [#error-handling]

When InResponseTo validation fails, users are redirected with an error query parameter:

* `?error=invalid_saml_response&error_description=Unknown+or+expired+request+ID` — The request ID was not found or has expired
* `?error=invalid_saml_response&error_description=Provider+mismatch` — The response was meant for a different provider
* `?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed` — IdP-initiated SSO is disabled

### Assertion Replay Protection [#assertion-replay-protection]

The SSO plugin includes assertion replay protection to prevent attackers from capturing and resubmitting valid SAML responses. Each SAML Assertion ID is tracked and rejected if reused.

<Callout type="info">
  Replay protection is **always enabled**. This is a critical security feature that prevents attackers from reusing intercepted SAML responses.
</Callout>

#### How It Works [#how-it-works]

1. When a SAML response is received, the Assertion ID is extracted from the XML
2. The system checks if this Assertion ID has been seen before
3. If it's a new assertion, it's stored in the database until its `NotOnOrAfter` expiration
4. If it's a duplicate (replay attack), the request is rejected

The SAML ACS endpoint (`/sso/saml2/sp/acs/:providerId`) is protected against assertion replay.

<Callout type="info">
  Replay protection uses the database verification table, so it works correctly in multi-instance deployments without additional configuration.
</Callout>

#### Error Handling [#error-handling-1]

When a replay attack is detected, users are redirected with an error:

* `?error=replay_detected&error_description=SAML+assertion+has+already+been+used` — The assertion ID was already used

### Timestamp Validation [#timestamp-validation]

The SSO plugin validates SAML assertion timestamps (`NotBefore` and `NotOnOrAfter`) to prevent acceptance of expired or future-dated assertions. This validation includes a configurable clock skew tolerance to account for time differences between servers.

#### SAML Specification Background [#saml-specification-background]

According to the **SAML 2.0 Core specification**, `NotBefore` and `NotOnOrAfter` attributes are **optional**. However, the widely-adopted **SAML2Int** (SAML V2.0 Implementation Profile for Federation Interoperability) specification **requires** these timestamps:

> "The Identity Provider MUST include a `<saml:Conditions>` element. Conditions restricting the period when the assertion is valid, the `@NotBefore` and `@NotOnOrAfter` MUST be included."

Better Auth provides flexibility to support both:

* **Default behavior**: Accepts assertions without timestamps (SAML 2.0 Core compliant) but logs a warning
* **Strict mode**: Rejects assertions without timestamps (SAML2Int compliant)

#### How It Works [#how-it-works-1]

For each SAML assertion:

* **NotBefore**: The assertion is rejected if current time is before `NotBefore - clockSkew`
* **NotOnOrAfter**: The assertion is rejected if current time is after `NotOnOrAfter + clockSkew`

#### Configuration [#configuration]

```ts title="auth.ts"
import { betterAuth } from "better-auth";
import { sso } from "@better-auth/sso";

const auth = betterAuth({
    plugins: [
        sso({
            saml: {
                // Clock skew tolerance (default: 5 minutes)
                clockSkew: 5 * 60 * 1000,
                // Require timestamps in assertions (default: false)
                requireTimestamps: false,
            },
        }),
    ],
});
```

#### Options [#options-1]

| Option              | Type      | Default          | Description                                                                                                                                  |
| ------------------- | --------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `clockSkew`         | `number`  | `300000` (5 min) | Clock skew tolerance in milliseconds. Allows for time differences between IdP and SP servers.                                                |
| `requireTimestamps` | `boolean` | `false`          | When `true`, assertions without `NotBefore`/`NotOnOrAfter` conditions are rejected. When `false`, they are accepted but a warning is logged. |

#### When to Enable `requireTimestamps` [#when-to-enable-requiretimestamps]

<Callout type="info">
  **Recommendation**: Enable `requireTimestamps: true` for enterprise and high-security deployments.
</Callout>

Enable `requireTimestamps: true` when:

* Your IdP follows **SAML2Int** (most enterprise IdPs like Okta, Azure AD, OneLogin)
* You need **SOC 2**, **ISO 27001**, or similar compliance
* You want to prevent acceptance of malformed or test assertions
* You're in a **production environment** with proper IdP configuration

Keep `requireTimestamps: false` (default) when:

* Integrating with **legacy IdPs** that may not include timestamps
* During **development/testing** with mock IdPs
* You need **maximum compatibility** with various IdP implementations

#### Stricter Security (Enterprise/Production) [#stricter-security-enterpriseproduction]

For enterprise environments following SAML2Int, configure stricter validation:

```ts title="auth.ts"
sso({
    saml: {
        clockSkew: 60 * 1000,      // 1 minute tolerance
        requireTimestamps: true,   // Reject assertions without timestamps (SAML2Int)
    },
})
```

#### Error Messages [#error-messages]

* **"SAML assertion is not yet valid"** — Current time is before the `NotBefore` timestamp (minus clock skew)
* **"SAML assertion has expired"** — Current time is after the `NotOnOrAfter` timestamp (plus clock skew)
* **"SAML assertion missing required timestamp conditions"** — Assertion has no timestamps and `requireTimestamps` is enabled

### Algorithm Validation [#algorithm-validation]

Better Auth validates SAML cryptographic algorithms and warns about deprecated ones (SHA-1, RSA 1.5, 3DES) by default.

```ts title="auth.ts"
sso({
    saml: {
        algorithms: {
            // "warn" (default) | "reject" | "allow"
            onDeprecated: "warn",
        },
    },
})
```

| Value      | Behavior                                    |
| ---------- | ------------------------------------------- |
| `"warn"`   | Log warning, allow authentication (default) |
| `"reject"` | Throw error, block authentication           |
| `"allow"`  | Silent, no validation                       |

For strict security (production):

```ts title="auth.ts"
sso({
    saml: {
        algorithms: {
            onDeprecated: "reject",
        },
    },
})
```

#### Supported Algorithms [#supported-algorithms]

**Signature algorithms:**

* `RSA-SHA256`, `RSA-SHA384`, `RSA-SHA512`
* `ECDSA-SHA256`, `ECDSA-SHA384`, `ECDSA-SHA512`

**Digest algorithms:**

* `SHA256`, `SHA384`, `SHA512`

**Deprecated (triggers warning/rejection):**

* `RSA-SHA1` (signature)
* `SHA1` (digest)
* `RSA 1.5` (key encryption)
* `3DES` (data encryption)

### Size Limits [#size-limits]

Better Auth enforces size limits on SAML payloads to protect against denial-of-service attacks via oversized XML.

| Option            | Default | Description                         |
| ----------------- | ------- | ----------------------------------- |
| `maxResponseSize` | 256KB   | Maximum SAML response size in bytes |
| `maxMetadataSize` | 100KB   | Maximum IdP or SP metadata size     |

#### Customizing Limits [#customizing-limits]

```ts title="auth.ts"
sso({
    saml: {
        maxResponseSize: 512 * 1024, // 512KB for enterprise IdPs with large group claims
        maxMetadataSize: 100 * 1024, // 100KB
    },
})
```

<Callout type="info">
  For true early rejection of oversized payloads (before they reach your application), configure size limits at your infrastructure level (nginx `client_max_body_size`, CDN settings, load balancer).
</Callout>

## Shared Redirect URI [#shared-redirect-uri]

By default, each OIDC provider gets its own callback URL (`/sso/callback/:providerId`). You can configure all providers to share a single redirect URI instead:

```ts title="auth.ts"
import { betterAuth } from "better-auth";
import { sso } from "@better-auth/sso";

const auth = betterAuth({
    plugins: [
        sso({
            redirectURI: "/sso/callback" // [!code highlight]
        })
    ]
});
```

The value can be a relative path or a full URL:

```ts
// Relative path (appended to your baseURL)
sso({ redirectURI: "/sso/callback" })

// Full URL
sso({ redirectURI: "https://login.example.com/callback" })
```

The provider ID is stored in the OAuth state so the callback can identify which provider initiated the flow.

<Callout type="info">
  When using a full URL, make sure it routes to your Better Auth instance.
</Callout>

<Callout type="info">
  This option only affects OIDC providers. SAML providers use a separate ACS endpoint that is configured automatically.
</Callout>

<Callout type="info">
  Both the shared endpoint (`/sso/callback`) and the per-provider endpoints (`/sso/callback/:providerId`) are always registered for backward compatibility, so existing integrations continue to work regardless of this setting.
</Callout>

## Domain verification [#domain-verification]

Domain verification allows your application to trust a new SSO provider after
validating ownership of every associated domain.

When a provider's domain is verified, it is also trusted for **automatic account linking**. This means that if a user signs in with an SSO provider (OIDC or SAML) and an existing account with the same email exists, the accounts will be linked automatically — as long as the user's email domain matches the provider's verified domain.

Domain verification is also required for automatic organization assignment
after a user signs in through another identity provider, such as a social
provider. Better Auth uses the stored user's verified email and assigns an
organization only when the verified domain maps to exactly one.

<Tabs items="[&#x22;client&#x22;, &#x22;server&#x22;]">
  <Tab value="client">
    ```ts title="auth-client.ts"
    const authClient = createAuthClient({
        plugins: [
            ssoClient({ // [!code highlight]
                domainVerification: { // [!code highlight]
                    enabled: true // [!code highlight]
                } // [!code highlight]
            }) // [!code highlight]
        ]
    })
    ```
  </Tab>

  <Tab value="server">
    ```ts title="auth.ts"
    import { betterAuth } from "better-auth";
    import { sso } from "@better-auth/sso";

    const auth = betterAuth({
        plugins: [
            sso({ // [!code highlight]
                domainVerification: { // [!code highlight]
                    enabled: true // [!code highlight]
                } // [!code highlight]
            }) // [!code highlight]
        ]
    });
    ```
  </Tab>
</Tabs>

Once enabled, make sure you migrate the database schema (again).

<Tabs items="[&#x22;migrate&#x22;, &#x22;generate&#x22;]">
  <Tab value="migrate">
    ```bash
    npx auth migrate
    ```
  </Tab>

  <Tab value="generate">
    ```bash
    npx auth generate
    ```
  </Tab>
</Tabs>

See the [Schema](#if-you-have-enabled-domain-verification) section to add the fields manually.

<Callout type="info">
  With domain verification disabled, Better Auth does not create organization
  memberships from email-domain matches. Signing in through a provider that is
  explicitly linked to an organization still provisions normally, and
  `signIn.sso` still resolves providers by email or domain.
</Callout>

### Verify your domain [#verify-your-domain]

When domain verification is enabled, every new SSO provider will be untrusted at first.
This means that new sign-ups or sign-ins will not be allowed until domain ownership has been verified.

To verify your ownership over a domain, follow these steps:

<Steps>
  <Step>
    #### Acquire verification token [#acquire-verification-token]

    When an SSO provider is registered, a **verification token** will be issued to the provider (it will be returned as part of the response).
    You can use this token to prove ownership over the domain.
  </Step>

  <Step>
    #### Create `TXT` DNS record [#create-txt-dns-record]

    To do this, you'll need to add a `TXT` record to your domain's DNS settings:

    * **Host:** `_better-auth-token-{your-provider-id}` (&#x2A;*Note:** An underscore is automatically prepended to follow DNS infrastructure subdomain conventions. The `better-auth-token` part can be customized through the `domainVerification.tokenPrefix` option)
    * **Value:** The verification token you were given.

    If the SSO provider lists multiple comma-separated domains, add this `TXT` record under every listed domain. For example, a provider with `domain: "company.com,subsidiary.com"` must publish the record for both `company.com` and `subsidiary.com`.

    **Save the record and wait for it to propagate.** This can take up to 48 hours, but it's usually much faster.
  </Step>

  <Step>
    #### Submit a validation request [#submit-a-validation-request]

    **Once the DNS record has propagated**, you can submit a validation request (See below)
  </Step>
</Steps>

### Domain validation request [#domain-validation-request]

Once you have configured your domain, you can use your `auth` instance to submit a validation request.
This request will either result in a rejection (could not prove your ownership over the domain)
or if the verification is successful, your SSO provider domain will be marked as verified.

Verification applies to the provider's exact domain configuration when the request starts. If the provider is updated or deleted while DNS verification is in progress, the request returns a `409` response with the `SSO_PROVIDER_CHANGED` code. Reload the provider, confirm its current domains, and retry verification. Request a new token first if the current provider does not have an active verification token.

**Endpoint:** `POST /sso/verify-domain`

### Client Side

```ts
const { data, error } = await authClient.sso.verifyDomain({
    providerId: "acme-corp", // required, The provider id
});
```

### Server Side

```ts
const data = await auth.api.verifyDomain({
    body: {
        providerId: "acme-corp", // required, The provider id
    },
    // This endpoint requires session cookies.
    headers: await headers(),
});
```

### Type Definition

```ts
type verifyDomain = {
  /**
  * The provider id
  */
  providerId: string = "acme-corp"
}
```

### Creating a new verification token [#creating-a-new-verification-token]

Every domain verification token will have a default expiry of 1 week since the moment it was issued
or the moment when the SSO provider was registered.

After that time, the token will expire and cannot longer be used. When that happens,
you can create a new verification token:

**Endpoint:** `POST /sso/request-domain-verification`

### Client Side

```ts
const { data, error } = await authClient.sso.requestDomainVerification({
    providerId: "acme-corp", // required, The provider id
});
```

### Server Side

```ts
const data = await auth.api.requestDomainVerification({
    body: {
        providerId: "acme-corp", // required, The provider id
    },
    // This endpoint requires session cookies.
    headers: await headers(),
});
```

### Type Definition

```ts
type requestDomainVerification = {
  /**
  * The provider id
  */
  providerId: string = "acme-corp"
}
```

### SAML Endpoints [#saml-endpoints]

The plugin automatically creates the following SAML endpoints:

* **SP Metadata**: `/api/auth/sso/saml2/sp/metadata?providerId={providerId}`
* **SAML ACS (Assertion Consumer Service)**: `/api/auth/sso/saml2/sp/acs/{providerId}` (supports both GET and POST)

### SAML Callback URL Configuration [#saml-callback-url-configuration]

The SAML ACS endpoint (`/api/auth/sso/saml2/sp/acs/{providerId}`) handles both **SP-initiated** and **IdP-initiated** SSO flows:

* **SP-initiated**: User clicks "Sign in with SSO" in your app → redirects to IdP → IdP POSTs SAMLResponse to callback
* **IdP-initiated**: User clicks app icon in IdP dashboard (Okta, Azure AD, etc.) → IdP POSTs SAMLResponse to callback

Better Auth derives the ACS URL automatically from your `baseURL` and `providerId`, so no SP-side configuration is required.

Your IdP must include an `AudienceRestriction` for this SP. The accepted audience is the SP entity ID, or the explicit `audience` value in `samlConfig` when you need to match an IdP-specific SP identifier. Better Auth also checks bearer `Recipient` and response `Destination` values against this provider's ACS endpoints.

The post-login redirect destination is controlled by the `callbackURL` parameter in the client-side `signIn.sso()` call:

```ts
await authClient.signIn.sso({
  providerId: "my-provider",
  callbackURL: "/dashboard", // Where the user lands after SSO
});
```

For unsolicited **IdP-initiated** flows where `signIn.sso()` is not used (and therefore no client-side `callbackURL` is set), configure `idpInitiatedCallbackUrl` in the provider's `samlConfig` or in the global `saml` plugin options. The provider setting takes precedence over the global setting, and the same fallback applies to validation error redirects. Better Auth-generated `RelayState` success and error callbacks retain higher priority. If neither fallback is configured, the existing redirect behavior is preserved.

When updating a provider, pass `null` for `samlConfig.idpInitiatedCallbackUrl` to remove its override and fall back to the global setting.

The callback route supports both GET and POST methods automatically, so you don't need to create any additional route handlers in your framework.

### Additional Provider Fields [#additional-provider-fields]

You can add custom columns to the `ssoProvider` table with `schema.ssoProvider.additionalFields`. These fields can be used to store provider metadata, such as a display name shown when an organization has multiple SSO providers.

```ts title="auth.ts"
import { betterAuth } from "better-auth";
import { sso } from "@better-auth/sso";

export const auth = betterAuth({
  plugins: [
    sso({
      schema: {
        ssoProvider: {
          additionalFields: {
            displayName: {
              type: "string",
              required: true,
            },
          },
        },
      },
    }),
  ],
});
```

Then include the field when registering or updating a provider:

```ts
await auth.api.registerSSOProvider({
  body: {
    providerId: "okta-main",
    displayName: "Okta",
    issuer: "https://idp.example.com",
    domain: "example.com",
    oidcConfig: {
      clientId: "client-id",
      clientSecret: "client-secret",
    },
  },
  headers,
});
```

## Schema [#schema]

The plugin requires additional fields in the `ssoProvider` table to store the provider's configuration.



<DatabaseTable name="ssoProvider" fields="ssoProviderTableFields" />

### If you have enabled domain verification: [#if-you-have-enabled-domain-verification]

The `ssoProvider` schema is extended as follows:



<DatabaseTable name="ssoProvider" fields="ssoProviderDomainVerificationFields" />

### IdP-Initiated SAML SSO [#idp-initiated-saml-sso]

Better Auth supports **IdP-initiated SSO flows**, where users access your application directly from their Identity Provider dashboard (e.g., Okta, Azure AD, OneLogin). This is common in enterprise environments where IT admins prefer centralized app access.

**How it works:**

1. User clicks your app icon in the IdP dashboard
2. IdP POSTs SAMLResponse to `/api/auth/sso/saml2/sp/acs/{providerId}`
3. Better Auth processes the assertion, creates a session, and redirects to your application
4. Browser follows the redirect with a GET request (handled automatically)

No additional route handler is required. The callback route automatically handles both GET and POST requests.

<Callout type="warn">
  IdP-initiated flows do not carry a Better Auth-generated RelayState containing a client-side callback URL, so the redirect falls back to the Better Auth base URL. In split-origin setups (where the app frontend is on a different origin than the Better Auth server), this lands on the auth server origin and can result in a 404. Configure `idpInitiatedCallbackUrl` globally under the `saml` plugin options or per provider under `samlConfig` (e.g., `https://app.example.com/dashboard`) to return successful sign-ins and validation errors to the frontend.
</Callout>

<Callout type="info">
  If you previously created a manual GET handler for the SAML callback route as a workaround, you can remove it after upgrading. Better Auth now handles GET requests automatically.
</Callout>

<Callout type="info">
  **Security:** Better Auth validates all redirect URLs to prevent open redirect attacks. Only relative paths (e.g., `/dashboard`) and URLs matching your configured `trustedOrigins` are allowed. Malicious URLs like `https://evil.com` or protocol-relative URLs (`//evil.com`) are automatically blocked.
</Callout>

For a detailed guide on setting up SAML SSO with examples for Okta and testing with DummyIDP, see our [SAML SSO with Okta](/docs/guides/saml-sso-with-okta).

## Options [#options-2]

### Server [#server]

**provisionUser**: A custom function to provision a user when they sign in with an SSO provider.

**provisionUserOnEveryLogin**: If `true`, the `provisionUser` callback runs on every login, not just on registration. Defaults to `false`.

**organizationProvisioning**: Options for provisioning users to an organization.

**defaultOverrideUserInfo**: Override user info with the provider info by default.

**disableImplicitSignUp**: Disable implicit sign up for new users.

If you want to allow account linking for specific trusted providers, enable the `accountLinking` option in your auth config and specify those providers in the `trustedProviders` list.



<TypeTable type="ssoOptionsType" />

