
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.
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 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():
// 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[].
Here's a complete server route creating a SAML connection on behalf of an Organization admin:
// 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.
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
- In the Okta admin console, go to Applications → Applications → Create App Integration → SAML 2.0.
- On the General Settings page, name the app and optionally upload a logo.
- On the Configure SAML page, paste the ACS URL and Entity ID from Clerk's Dashboard. Leave
Name ID formatatEmailAddressandApplication usernameatEmail. - Under Attribute Statements, add:
email→user.emailfirstName→user.firstNamelastName→user.lastName
- On the Feedback page, select I'm an Okta customer adding an internal app.
- After creation, open the Sign On tab, click View Setup Instructions, and copy the Identity Provider metadata URL.
- 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
- In the Azure portal, go to Microsoft Entra ID → Enterprise applications → New application → Create your own application → Non-gallery.
- Inside the new app, go to Single sign-on → SAML.
- Under Basic SAML Configuration, paste the Reply URL (Clerk's ACS URL) and the Identifier (Clerk's Entity ID).
- 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:
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- Under SAML Certificates, copy the App Federation Metadata Url and paste it into Clerk's IdP metadata URL field.
Google Workspace walkthrough
- 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.
- Name the app. On the Google IdP details page, copy the SSO URL, Entity ID, and download the X.509 certificate.
- On the Service provider details page, paste Clerk's ACS URL and Clerk's Entity ID. Set Name ID format to
EMAILand Name ID toBasic Information > Primary email. - Under Attribute mapping, map:
Primary email→mailFirst name→firstNameLast name→lastName
- 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:
// 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. 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, 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→ Clerkusernameemail.value→ Clerk primary emailname.givenName→ Clerkfirst_namename.familyName→ Clerklast_nameactive→ Clerk user enabled/disabled flag
Events
SCIM events fire as Clerk user.created and user.updated webhooks, plus dedicated SCIM webhook events:
scim.user.createdscim.user.patchedscim.user.deletedscim.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.
- Custom attribute mapping into
publicMetadata. Sync IdP attributes likedepartment,employee_id, orcost_centerinto the user'spublicMetadata; while Directory Sync is enabled, these fields are read-only in Clerk (the IdP is the system of record). - 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
adminsandviewers— 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
pnpm add @clerk/nextjs@^7Environment 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:
# .env.local
CLERK_PUBLISHABLE_KEY=pk_test_xxxxxxxxxxxxxxxxxxxx
CLERK_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxxUse 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.
// 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.
Mitigations for SAML-protected enterprise routes:
- Pass
unauthenticatedUrlandunauthorizedUrlexplicitly 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 (
orgIdis null), redirect to an org-selection CTA like/select-orgor 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. UseunauthorizedUrl: '/request-sso'with an SSO-specifichas()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 notesauth.protect()"doesn't offer control over the response" whilehas()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.
// 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:
// 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:
// 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:
// 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-samlDocker 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_, notpk_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*.localhostdomain 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 anyboxyhq/mock-samlPRIVATE_KEYmust be per-environment and managed through a secrets manager (Vercel env, Doppler, Infisical). Never commit.envfiles 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
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
- How to add SSO and SAML to my SaaS Product
- How to add SSO and SAML to my SaaS Product - Part 2
- How to add SSO and SAML to my SaaS Product - Part 3 (you are here)
- How to add SSO and SAML to my SaaS Product - Part 4