# How to add SSO and SAML to my SaaS Product - Part 3

> Part 3 of 4. Start with [How to add SSO and SAML to my SaaS Product](https://clerk.com/articles/how-to-add-sso-and-saml-to-my-saas-product.md).

The previous parts covered the architectural foundations of enterprise authentication and how to prepare your SaaS app for SSO. Now it's time to build. This part is the complete Clerk implementation guide: mapping tenants to Clerk Organizations, configuring SAML connections, wiring up your Next.js 16 app, and testing end-to-end.

### Step 2: Model customers as Organizations

Enterprise SSO in Clerk is organization-scoped. The [`orgId`](https://clerk.com/glossary.md#organizations) is part of the session token, role assignment derives from organization membership, and SAML connections attach to a single organization (or a set, via Clerk's multi-domain feature). Before creating a SAML connection, the customer must exist as an Organization.

#### Mapping tenants to Clerk Organizations

If your app has a multi-tenant data model (workspaces, teams), map each tenant to a Clerk Organization. Use `<OrganizationSwitcher hidePersonal={true} />` in the app shell for moving between organizations, and `<CreateOrganization />` during onboarding so the first user from a new tenant can create the Organization.

On the server, Organization context is available via `await auth()`:

```ts
// app/dashboard/page.tsx
import { auth } from '@clerk/nextjs/server'
import NoOrganizationCTA from './_components/no-org-cta'

export default async function Dashboard() {
  const { userId, orgId, orgRole } = await auth()

  if (!orgId) {
    return <NoOrganizationCTA />
  }

  // Your org-scoped data fetch goes here.
  // orgId is your multi-tenant foreign key.
  return <DashboardContent orgId={orgId} orgRole={orgRole} />
}
```

On the client, `useOrganization()` and `useOrganizationList()` provide the same data reactively. The Organization primitive anchors everything that follows — make `organizationId` a foreign key on every tenant-owned resource.

#### Verified domains and automatic organization routing

Clerk's Verified Domains feature routes users to the right Organization without manual invitation. An admin verifies `acme.com` once (via email OTP to `admin@acme.com` or a DNS TXT record); afterward, anyone signing up with an `@acme.com` email is automatically routed to the Acme Corp organization.

Three enrollment modes exist per organization:

- `manual_invitation` — default; admins explicitly invite users.
- `automatic_invitation` — users from verified domains are added as members on first sign-in without an invite.
- `automatic_suggestion` — users from verified domains see a "Join Acme Corp" prompt on first sign-in but aren't added automatically.

For B2B SaaS, `automatic_invitation` plus SAML JIT provisioning is the most common pattern: the first time an Acme employee signs in via SAML, they land in the Acme organization with the default role.

Default limits: 5 members per organization (configurable per plan) and a 100-organization create-limit per user. `maxAllowedMemberships` controls the member cap (`0` = unlimited). One constraint: a domain can't be claimed by more than one organization at once, and domain ownership can't overlap between an Enterprise SSO connection and a plain verified-domain enrollment.

### Step 3: Create a SAML connection

#### Generating your Service Provider metadata

In the Clerk Dashboard, inside the Organization you're configuring, go to **SSO connections** → **Add SAML**. Clerk auto-generates the ACS URL and Entity ID per connection. Copy both to paste into the IdP configuration in Step 4.

#### Supported IdPs

Clerk supports Okta, Microsoft Entra ID (formerly Azure AD), Google Workspace, and generic SAML (`saml_custom`). The generic option covers OneLogin, PingFederate, JumpCloud, ADFS, and anything speaking SAML 2.0. For Okta and Entra ID, Clerk provides guided walkthroughs surfacing IdP-specific quirks inline.

#### Backend API: unified `/enterprise_connections` (March 2026)

If you're building an admin-facing form for customer IT admins, you'll create SAML connections programmatically. Clerk unified the `/saml_connections` and `/oidc_connections` endpoints into a single `/enterprise_connections` endpoint on March 9, 2026: protocol-specific parameters now nest under a `saml: {}` or `oidc: {}` object, and `domain` became `domains: string[]`.

> `clerkClient` in `@clerk/nextjs` v7 / Core 3 is an **async factory**. You must await it. Direct property access like `clerkClient.enterpriseConnections.createEnterpriseConnection(...)` fails at runtime with `TypeError: clerkClient.enterpriseConnections is undefined`. Always invoke the factory: `(await clerkClient()).enterpriseConnections...`. Teams migrating from `@clerk/nextjs` v5 get bitten by this because TypeScript may not flag the legacy shape.

Here's a complete server route creating a SAML connection on behalf of an Organization admin:

```ts
// app/api/sso/create/route.ts
import { auth, clerkClient } from '@clerk/nextjs/server'
import { NextResponse } from 'next/server'

export async function POST(req: Request) {
  const { userId, orgId, has } = await auth()

  if (!userId || !orgId) {
    return NextResponse.json({ error: 'unauthenticated' }, { status: 401 })
  }

  if (!has({ role: 'org:admin' })) {
    return NextResponse.json({ error: 'forbidden' }, { status: 403 })
  }

  const { name, domain, idpMetadataUrl } = await req.json()

  const client = await clerkClient()
  const connection = await client.enterpriseConnections.createEnterpriseConnection({
    name,
    domains: [domain],
    organizationId: orgId,
    active: true,
    syncUserAttributes: true,
    saml: {
      idpMetadataUrl,
      allowIdpInitiated: false,
      allowSubdomains: false,
      forceAuthn: false,
      attributeMapping: {
        emailAddress: 'user.email',
        firstName: 'user.firstName',
        lastName: 'user.lastName',
      },
    },
  })

  return NextResponse.json(connection)
}
```

The settings here are intentional. `allowIdpInitiated: false` disables unsolicited IdP-initiated assertions by default — matching NIST SP 800-63C (final, July 31, 2025) requirements for RP-initiated federation at FAL2 and FAL3. `allowSubdomains: false` prevents tenant-crossover if a customer later adds a subsidiary on a different subdomain. `forceAuthn: false` is the sensible default; `true` forces re-authentication on every sign-in, which is hostile for typical workforce SSO.

The full set of parameters on `EnterpriseConnectionSamlParams` (per `packages/backend/src/api/endpoints/EnterpriseConnectionApi.ts` in `clerk/javascript`): `allowIdpInitiated`, `allowSubdomains`, `attributeMapping`, `forceAuthn`, `idpCertificate`, `idpEntityId`, `idpMetadata`, `idpMetadataUrl`, `idpSsoUrl`. The SDK's `createEnterpriseConnection` takes no `provider` field — `CreateEnterpriseConnectionParams` omits it, and Clerk auto-detects the IdP slug from the metadata document. `provider` appears on `UpdateEnterpriseConnectionParams`, for overriding the inferred value on an existing connection.

> `clerkClient.samlConnections` is the legacy surface as of the March 9, 2026 unification. The methods still function, but new code should target `enterpriseConnections`. The two create signatures are incompatible — the legacy `CreateSamlConnectionParams` shape is flat with SAML fields at the top level and requires `provider: SamlIdpSlug` and a singular `domain: string`, while the new `CreateEnterpriseConnectionParams` nests protocol-specific fields under `saml: { ... }` or `oidc: { ... }` and uses `domains: string[]`. See the [March 9, 2026 Backend API changelog entry](https://clerk.com/changelog/2026-03-09-bapi-enterprise-connections.md) for the full migration matrix.

### Step 4: Configure the Identity Provider side

Each IdP's configuration differs in UI and claim naming, but the underlying fields are the same: ACS URL, Entity ID, attribute mapping, signing certificate.

#### Okta walkthrough

1. In the Okta admin console, go to **Applications** → **Applications** → **Create App Integration** → **SAML 2.0**.
2. On the **General Settings** page, name the app and optionally upload a logo.
3. On the **Configure SAML** page, paste the ACS URL and Entity ID from Clerk's Dashboard. Leave `Name ID format` at `EmailAddress` and `Application username` at `Email`.
4. Under **Attribute Statements**, add:
   - `email` → `user.email`
   - `firstName` → `user.firstName`
   - `lastName` → `user.lastName`
5. On the **Feedback** page, select **I'm an Okta customer adding an internal app**.
6. After creation, open the **Sign On** tab, click **View Setup Instructions**, and copy the Identity Provider metadata URL.
7. In Clerk's Dashboard, paste the metadata URL into the SAML connection's **IdP metadata URL** field and save.

Verification: click **Test Connection** in Clerk's dashboard. A successful test ends with a "Connection verified" banner.

#### Entra ID (formerly Azure AD) walkthrough

1. In the Azure portal, go to **Microsoft Entra ID** → **Enterprise applications** → **New application** → **Create your own application** → **Non-gallery**.
2. Inside the new app, go to **Single sign-on** → **SAML**.
3. Under **Basic SAML Configuration**, paste the Reply URL (Clerk's ACS URL) and the Identifier (Clerk's Entity ID).
4. Under **Attributes & Claims**, add the following claims. Entra ID uses long WS-\* URIs by default (XML namespace identifiers, not reachable HTTP URLs). Leave them at the defaults or rename them:

```text
http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress → user.mail
http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname    → user.givenname
http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname      → user.surname
```

5. Under **SAML Certificates**, copy the **App Federation Metadata Url** and paste it into Clerk's **IdP metadata URL** field.

> Entra ID's "add custom claims" UI has a subtle quirk: if you paste a custom attribute (like a department code) and Entra stores it as a typed value instead of a string, Clerk's SAML response parser will reject it. Cast custom attributes to strings explicitly in the Entra claim transformation.

#### Google Workspace walkthrough

1. In the Google Admin console, go to **Apps** → **Web and mobile apps** → **Add app** → **Add custom SAML app**. You must be a Super Admin — delegated admins cannot create custom SAML apps.
2. Name the app. On the Google IdP details page, copy the **SSO URL**, **Entity ID**, and download the X.509 certificate.
3. On the **Service provider details** page, paste Clerk's ACS URL and Clerk's Entity ID. Set **Name ID format** to `EMAIL` and **Name ID** to `Basic Information > Primary email`.
4. Under **Attribute mapping**, map:
   - `Primary email` → `mail`
   - `First name` → `firstName`
   - `Last name` → `lastName`
5. In Clerk's Dashboard, paste the Google SSO URL and Entity ID, and upload the X.509 certificate (Google does not expose a metadata URL for custom SAML apps — you configure it statically).

If the customer runs Google Workspace and hasn't required per-tenant SAML isolation, consider **EASIE** instead — it replaces these steps with a domain verification. Many customers prefer it; others insist on SAML for compliance. Ask.

### Step 5: Attribute mapping and custom claims

Clerk's default SAML attribute mapping covers the universal fields: `email_address`, `first_name`, `last_name`. Most integrations stop there and derive everything else from SCIM or the user's first in-app interaction.

For custom claims — like a user's `department` from Okta appearing in your app's role logic — Clerk has a `public_metadata` bridge. Prepend the IdP claim name with `public_metadata_` and Clerk surfaces it on the user object:

```ts
// In Okta: add an attribute statement
// Name: public_metadata_department
// Value: user.department

// In your Next.js server component
import { auth, currentUser } from '@clerk/nextjs/server'

export default async function Dashboard() {
  const user = await currentUser()
  const department = user?.publicMetadata?.department as string | undefined

  return <DashboardFor department={department} />
}
```

Custom claim mapping runs on every sign-in (with `syncUserAttributes: true`), so IdP updates propagate on the user's next session. `public_metadata` is readable client-side — don't use it for authorization decisions requiring tamper-resistance; use SCIM group mappings or server-side role assignment for that.

### Step 6: Just-in-Time (JIT) provisioning

JIT provisioning is on by default. On first SAML sign-in, Clerk creates the Clerk User, adds them to the Organization owning the matching verified domain, and assigns the default role. The user's email, first name, and last name come from the SAML assertion — no pre-provisioning required.

The "Sync user attributes during sign-in" toggle (on by default) controls whether later sign-ins update attributes from the assertion. Turn it off if your app is the source of truth for display names and shouldn't have them overwritten by IdP changes.

JIT alone isn't enough for enterprise procurement: it creates users but doesn't deprovision them. For deprovisioning, use SCIM.

### Step 7: Automated provisioning with SCIM (Directory Sync)

Clerk's core Directory Sync went **GA on April 16, 2026** — see the [Directory Sync GA changelog entry](https://clerk.com/changelog/2026-04-16-directory-sync.md). Two sub-features (custom attribute mapping into `publicMetadata`, and groups-to-role mapping with precedence ordering) remain in public beta; both are covered on the [Directory Sync docs page](https://clerk.com/docs/guides/configure/auth-strategies/enterprise-connections/directory-sync.md), which carries scoped beta callouts only on those sections. The rest is GA and enabled for all users.

#### Supported IdPs

- **Okta** — officially supported with a dedicated OIN app catalog listing.
- **Microsoft Entra ID** — officially supported.
- Other SCIM 2.0 providers (JumpCloud, OneLogin, PingOne) generally work with the generic SCIM endpoint configuration but are best-effort.

#### Configuration

In the Clerk Dashboard, inside the Organization, go to **Directory Sync** → **Enable Directory Sync**. Clerk generates a SCIM token scoped to that Organization. Copy it.

In Okta, go to the SAML app you created in Step 4 → **Provisioning** → **Configure API Integration** → paste the SCIM token and Clerk's SCIM endpoint URL. Enable **Create Users**, **Update User Attributes**, and **Deactivate Users**.

In Entra ID, go to the enterprise application → **Provisioning** → **Provisioning Mode: Automatic** → paste the SCIM URL and token.

#### Synced attributes

- `userName` → Clerk `username`
- `email.value` → Clerk primary email
- `name.givenName` → Clerk `first_name`
- `name.familyName` → Clerk `last_name`
- `active` → Clerk user enabled/disabled flag

#### Events

SCIM events fire as Clerk `user.created` and `user.updated` webhooks, plus dedicated SCIM webhook events:

- `scim.user.created`
- `scim.user.patched`
- `scim.user.deleted`
- `scim.provisioning_failed`

Wire these webhooks to whatever needs user-lifecycle events — audit log, billing, or customer success tooling.

#### Two public-beta sub-features to note

Both are production-usable for most teams but may see API tweaks before GA.

1. **Custom attribute mapping into `publicMetadata`.** Sync IdP attributes like `department`, `employee_id`, or `cost_center` into the user's `publicMetadata`; while Directory Sync is enabled, these fields are read-only in Clerk (the IdP is the system of record).
2. **Groups-to-role mapping with precedence ordering.** When Okta pushes group memberships via SCIM, Clerk maps group names to Organization roles. Precedence decides the winner when a user is in both `admins` and `viewers` — you configure the list in the dashboard.

#### Pricing

Directory Sync is **included with each enterprise connection at no extra charge** — consistent with Clerk's pricing-page and changelog messaging. This differs from Auth0, where Inbound SCIM is scoped per plan (and included on the February 2026 Free tier), and from WorkOS, which charges $125/month per Directory Sync connection separately from SSO.

### Step 8: Wire up your Next.js 16 app

#### Install

```sh
pnpm add @clerk/nextjs@^7
```

#### Environment variables

Clerk reads publishable and secret keys from environment variables. Put them in `.env.local` for local development and use `vercel env add` (or equivalent) for staging and production:

```sh
# .env.local
CLERK_PUBLISHABLE_KEY=pk_test_xxxxxxxxxxxxxxxxxxxx
CLERK_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxx
```

Use `pk_test_*` / `sk_test_*` for Development instances, `pk_live_*` / `sk_live_*` for Production. Vercel: `vercel env add CLERK_SECRET_KEY` for each environment. Never commit either key.

#### `proxy.ts` replaces `middleware.ts`

Next.js 16 renamed `middleware.ts` to `proxy.ts` and ships a codemod to migrate existing projects: `npx @next/codemod@canary middleware-to-proxy .`. The Next.js 16 file-convention reference states verbatim: _"The file must export a single function, either as a default export or named `proxy`."_

Clerk's Next.js quickstart uses `export default clerkMiddleware(...)`, exporting the returned value directly — the shorter idiomatic choice. Teams preferring the named form can write `export const proxy = clerkMiddleware(...)` with no other change.

```ts
// proxy.ts
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'

const isProtected = createRouteMatcher(['/dashboard(.*)', '/api/private(.*)'])
const isEnterpriseRoute = createRouteMatcher(['/enterprise(.*)'])

export default clerkMiddleware(async (auth, req) => {
  if (isEnterpriseRoute(req)) {
    await auth.protect({
      unauthenticatedUrl: new URL('/sign-in', req.url).toString(),
      unauthorizedUrl: new URL('/not-authorized', req.url).toString(),
    })
    return
  }

  if (isProtected(req)) {
    await auth.protect()
  }
})

export const config = {
  matcher: ['/((?!.*\\..*|_next).*)', '/', '/(api|trpc)(.*)'],
}
```

#### Error handling for unauthorized users

`auth.protect()`'s response matrix surprises first-time users. An authenticated-but-unauthorized user gets a **thrown 404, not a 403** — with no JSON body. This is intentional (don't leak which roles exist) but is bad UX on a SAML-protected enterprise route, so override it explicitly.

| State                                                         | `auth.protect()` response                |
| ------------------------------------------------------------- | ---------------------------------------- |
| Authenticated + authorized                                    | Returns `Auth` object; request continues |
| Authenticated + unauthorized (wrong role, permission, or org) | Thrown 404 (not 403, not JSON)           |
| Unauthenticated, document request                             | Redirect to sign-in URL                  |
| Unauthenticated, session-token API request                    | Thrown 404                               |
| Unauthenticated, machine-token API request                    | Thrown 401                               |

Mitigations for SAML-protected enterprise routes:

- Pass `unauthenticatedUrl` and `unauthorizedUrl` explicitly so users land on a branded page (`/sign-in`, `/not-authorized`, `/upgrade-sso`), as the snippet above shows.
- For signed-in users with no Organization (`orgId` is null), redirect to an org-selection CTA like `/select-org` or render an inline `<OrganizationSwitcher />` — a thrown 404 is wrong for a user who hasn't picked a workspace yet.
- For users whose Organization is valid but whose email domain isn't configured for SSO yet, route to a "request access" page (`/request-sso`) explaining what their IT admin must do. Use `unauthorizedUrl: '/request-sso'` with an SSO-specific `has()` check.
- For API route handlers needing structured JSON errors, prefer `has()` manually. `auth.protect()` works in Route Handlers but throws a bare 404/401 with no JSON body. Clerk's authorization-checks guide notes `auth.protect()` "doesn't offer control over the response" while `has()` offers "flexibility and control."

#### `<ClerkProvider>` placement

The `<ClerkProvider>` wrapper must live **inside** `<body>` for Next.js 16 cache components to work correctly. Putting it outside `<body>` is a common mistake migrating from older Next.js versions.

```tsx
// app/layout.tsx
import { ClerkProvider } from '@clerk/nextjs'

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <ClerkProvider>{children}</ClerkProvider>
      </body>
    </html>
  )
}
```

#### Custom sign-in with the `enterprise_sso` strategy

For identity-first flows — the user enters their email and your app routes them to the right IdP — use `authenticateWithRedirect` with `strategy: 'enterprise_sso'`. Clerk resolves the email domain to the correct SAML connection automatically:

```tsx
// app/sign-in/[[...sign-in]]/page.tsx
'use client'

import { useSignIn } from '@clerk/nextjs'
import { useState } from 'react'

export default function SignInPage() {
  const { signIn, isLoaded } = useSignIn()
  const [email, setEmail] = useState('')

  if (!isLoaded) return null

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault()
    await signIn.authenticateWithRedirect({
      identifier: email,
      strategy: 'enterprise_sso',
      redirectUrl: '/sign-in/sso-callback',
      redirectUrlComplete: '/',
    })
  }

  return (
    <form onSubmit={handleSubmit}>
      <label>
        Work email
        <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
      </label>
      <button type="submit">Continue with SSO</button>
    </form>
  )
}
```

Wire up the callback page:

```tsx
// app/sign-in/sso-callback/page.tsx
import { AuthenticateWithRedirectCallback } from '@clerk/nextjs'

export default function SSOCallback() {
  return <AuthenticateWithRedirectCallback />
}
```

When the user returns from their IdP, the callback component finishes the flow and redirects to `redirectUrlComplete`.

#### Role-scoped route protection

Inside a server component or route handler, use `has()` for authorization checks:

```ts
// app/api/enterprise/route.ts
import { auth } from '@clerk/nextjs/server'

export async function GET() {
  const { isAuthenticated, orgId, has } = await auth()

  if (!isAuthenticated) {
    return Response.json({ error: 'unauthenticated' }, { status: 401 })
  }

  if (!orgId) {
    return Response.json({ error: 'org_required', code: 'NO_ORG' }, { status: 403 })
  }

  if (!has({ role: 'org:admin' })) {
    return Response.json({ error: 'forbidden', code: 'ROLE_REQUIRED' }, { status: 403 })
  }

  return Response.json({ data: 'your enterprise data here' })
}
```

This returns structured JSON errors your SPA or native client can parse, instead of the thrown 404s from `auth.protect()`.

### Step 9: Test end-to-end

#### Staging IdPs

For team testing without involving a real corporate IdP, use:

- **MockSAML.com** (BoxyHQ / Ory) — hosted mock IdP, free, public.
- **SAMLTest.id** (Shibboleth project) — hosted mock IdP, public.
- **DummyIDP.com** — minimal hosted mock, fast round-trip.
- **`boxyhq/mock-saml`** Docker image — self-hosted mock for CI and air-gapped testing.
- **Keycloak** Docker image — heavier, fuller-fidelity; good for SCIM too.

For staging mirroring production, use the free **Okta Developer Integrator Plan** (`developer.okta.com`) or a free **Microsoft Entra** tenant.

#### Isolating mock IdPs from production

A mock IdP with publicly known signing keys, trusted against a production Clerk instance, is functionally an authentication bypass for any email claim the connection accepts. Isolate strictly:

- **Use a Development Clerk instance for all mock IdP testing.** Clerk's Development (`pk_test_*` / `sk_test_*`) and Production (`pk_live_*` / `sk_live_*`) instances are fully separated; SSO connections do not copy between dev and prod automatically.
- **Before adding a mock connection, verify:** (1) the publishable key starts with `pk_test_`, not `pk_live_`; (2) the Clerk Dashboard shows the Development banner; (3) the IdP metadata URL does not resolve to a production or customer-reachable domain; (4) the claimed email domain is a disposable test domain you own (e.g., `acme-test.example`) — never a `*.example`, `*.test`, or `*.localhost` domain you don't control — and will never be verified in production; (5) the mock IdP container is not publicly exposed with default admin credentials.
- **Never paste a mock IdP's X.509 cert into a production tenant.** OWASP's SAML Security Cheat Sheet is explicit: signing keys are a top security asset; trust should only be established via verified metadata URLs, not hand-pasted certs of unknown provenance.
- **Rotate out before promotion.** Before any Organization or tenant moves to production, delete every connection whose IdP entityID contains `mocksaml.com`, `samltest.id`, `dummyidp.com`, `localhost`, `*.ngrok.io`, `*.ngrok-free.app`, or an internal Docker or Kubernetes hostname.
- **Environment variable hygiene.** `CLERK_SECRET_KEY`, IdP signing keys, and any `boxyhq/mock-saml` `PRIVATE_KEY` must be per-environment and managed through a secrets manager (Vercel env, Doppler, Infisical). Never commit `.env` files containing mock IdP keys.

Real-world framing: the March 2026 CVE-2026-3055 Citrix NetScaler SAML IdP exposure, where crafted `SAMLRequest` payloads leaked session data via the `NSC_TASS` cookie and landed in CISA's KEV catalog, demonstrates the broader class of "test-grade IdP left reachable from production" risk. Mock IdPs that stay alive after their usefulness ends become production attack surface.

**Before promoting to production, also confirm:** every mock-IdP container from testing is torn down and its hostnames no longer resolve; all `boxyhq/mock-saml` `PRIVATE_KEY` and signing-key env vars are rotated out of your production secrets managers and absent from every running deployment; and no customer-facing `/sign-in`, onboarding, or admin-portal page references mock IdP docs or testing URLs.

#### Validating the SAML response

Three tools worth having:

- **SAML-tracer** browser extension (Chrome and Firefox). Captures the raw SAML request and response in the browser; decode-on-hover makes debugging "why did this fail" tractable.
- **SAMLTool.com** online validator. Paste a base64-encoded SAML response and get the decoded XML with signature validation status.
- **Structured logging on your own side.** Log `Issuer`, `Audience`, `Destination`, `NotBefore`, `NotOnOrAfter`, and the signing cert fingerprint as structured fields. Redact PII (emails, names) from logs unless you have a legal basis to retain them.

#### Troubleshooting common errors

| Error                                  | Likely cause                                                   | Fix                                                                            |
| -------------------------------------- | -------------------------------------------------------------- | ------------------------------------------------------------------------------ |
| `Invalid Signature`                    | Certificate rotation mismatch; cert in Clerk is stale          | Re-download IdP metadata; Clerk auto-pulls if you used metadata URL            |
| `Audience mismatch`                    | Entity ID wrong in IdP config                                  | Compare Clerk's Entity ID to IdP's `audienceRestriction` element               |
| `NotOnOrAfter exceeded`                | Clock skew between SP and IdP, or assertion replay             | Sync server clocks via NTP; check for replay in logs                           |
| `AssertionConsumerServiceURL mismatch` | ACS URL wrong in IdP config                                    | Compare Clerk's ACS URL (copy-paste) to IdP's Reply URL                        |
| `Signature element not found`          | IdP is signing the Response, not the Assertion (or vice versa) | In Clerk dashboard, flip "Expect signed response" vs "Expect signed assertion" |

### Conclusion

You've now modeled your tenants, created SAML connections, configured Identity Providers, wired everything into your Next.js app, and set up a secure testing environment for your SSO flows. The next part covers advanced security considerations and the common pitfalls to avoid when deploying enterprise authentication to production.

### FAQ

**Can I use a single SAML connection across multiple organizations?**
No — SAML connections in Clerk are organization-scoped. For a customer with multiple organizations (e.g., subsidiaries), use Clerk's multi-domain feature or create separate connections per organization.

**Why does `auth.protect()` throw a 404 instead of a 403 for unauthorized users?**
It's an intentional security design that avoids leaking which roles or permissions exist. Where that UX is suboptimal, pass `unauthorizedUrl` to `auth.protect()` explicitly, or use `has()` to return structured JSON errors.

**Does Clerk support IdP-initiated SSO?**
Yes, but it is disabled by default (`allowIdpInitiated: false`) to align with NIST SP 800-63C recommendations for RP-initiated federation. You can enable it per connection if a customer explicitly requires it.

**How do I test SAML without a real enterprise IdP?**
Yes — use mock IdPs like MockSAML.com or SAMLTest.id, but strictly isolate them to your Development Clerk instance so they never become a production attack surface.

## In this series

1. [How to add SSO and SAML to my SaaS Product](https://clerk.com/articles/how-to-add-sso-and-saml-to-my-saas-product.md)
2. [How to add SSO and SAML to my SaaS Product - Part 2](https://clerk.com/articles/how-to-add-sso-and-saml-to-my-saas-product-2.md)
3. **How to add SSO and SAML to my SaaS Product - Part 3** (you are here)
4. [How to add SSO and SAML to my SaaS Product - Part 4](https://clerk.com/articles/how-to-add-sso-and-saml-to-my-saas-product-4.md)
