← All posts
·13 min read

Claude Code with WorkOS: Enterprise SSO, SCIM, and AuthKit

Claude CodeWorkOSEnterprise AuthSSO
Claude Code with WorkOS: Enterprise auth, SSO, and directory sync

Why WorkOS without CLAUDE.md mishandles multi-tenant boundaries

WorkOS is the standard way to add enterprise authentication, single sign-on, and directory sync to a B2B SaaS application in 2026. The platform covers Sign In with Google and Microsoft, SAML SSO with any IdP, SCIM directory sync, and the AuthKit hosted sign-in experience that replaces a custom auth UI. The breadth is the strength. It is also the failure mode when Claude Code touches it. Without explicit constraints, Claude generates code that conflates organization_id and connection_id (two different identifiers with very different semantics), skips the SCIM webhook signature verification, hardcodes connection-specific IDs in route handlers, and treats AuthKit sessions as if they were plain JWTs without the refresh and revocation lifecycle.

The most common Claude defaults that hurt WorkOS integrations: importing from @workos-inc/node correctly but then calling workos.userManagement.authenticateWithPassword from a public route handler (which works but is the wrong API for AuthKit-managed flows), reading the AuthKit session cookie directly with cookies().get() instead of getSession() from the framework SDK (missing the automatic refresh logic), allowing a user to access an organization without verifying their membership for that specific organization, hardcoding the connection_id of a single SSO provider in the login redirect, and treating SCIM provisioned users as the same shape as JIT-provisioned users when they have different fields populated.

This guide covers the CLAUDE.md configuration that locks Claude Code into WorkOS's correct model: AuthKit sessions retrieved through the framework SDK, every API call scoped to the active organization, webhook signatures verified before any side effect, and the SCIM vs JIT user types handled distinctly. If you are building the API routes that sit behind AuthKit, Claude Code with Next.js covers the broader route handler patterns. For payment-gated tenant features, Claude Code with Stripe covers the customer-to-organization mapping that pairs with WorkOS organizations.

The WorkOS CLAUDE.md template

The CLAUDE.md at your project root is read at the start of every Claude Code session. For a WorkOS integration it needs to declare: the SDK version, the AuthKit session retrieval pattern, the organization scoping policy, the SCIM webhook handling, the SSO provider abstraction, and the hard rules that block the mistakes Claude makes most often.

# WorkOS rules

## Stack
- @workos-inc/node ^7.x server SDK
- @workos-inc/authkit-nextjs ^2.x (or authkit-react for SPAs)
- TypeScript 5.x strict, Next.js 14+ app router
- WORKOS_API_KEY, WORKOS_CLIENT_ID, WORKOS_COOKIE_PASSWORD in .env

## Project structure
- src/lib/workos.ts             : WorkOS client singleton
- src/app/auth/                 : AuthKit callback + sign-in pages
- src/app/api/webhooks/workos/  : WorkOS webhook endpoint
- src/middleware.ts             : authkitMiddleware for protected routes
- src/lib/get-session.ts        : Wrapper around AuthKit getSession()

## Client initialisation
- ALWAYS use a singleton: import { workos } from "@/lib/workos"
- src/lib/workos.ts content:
  import { WorkOS } from "@workos-inc/node";
  export const workos = new WorkOS(process.env.WORKOS_API_KEY!, {
    clientId: process.env.WORKOS_CLIENT_ID,
  });

## Session retrieval (HARD RULE)
- NEVER read the AuthKit cookie directly via cookies().get()
- ALWAYS use getSession() from @workos-inc/authkit-nextjs
- The getSession() helper handles refresh, validation, and revocation
- Return type: { user, sessionId, organizationId, impersonator } | null

## Organization scoping (HARD RULE)
- EVERY API route that reads data MUST verify the user belongs to the org
- Pattern: const { user, organizationId } = await getSession();
         if (!organizationId) throw 401;
         if (data.organizationId !== organizationId) throw 403;
- NEVER trust an organization_id from request params alone
- NEVER share organization-scoped data across tenants

## connection_id vs organization_id
- connection_id: identifies a SINGLE SSO connection (one IdP, one config)
- organization_id: identifies the customer organization (may have many connections)
- For sign-in: prefer organization-scoped redirects, NEVER connection-specific
- AuthKit handles connection routing automatically given an organization_id

## SCIM webhooks
- Endpoint: src/app/api/webhooks/workos/route.ts
- ALWAYS verify webhook signature with workos.webhooks.constructEvent()
- Handle: user.created, user.updated, user.deleted (and group equivalents)
- Idempotent handlers: WorkOS may deliver the same event twice
- After processing: return 200 to acknowledge, 4xx for validation failure

## JIT vs SCIM users
- JIT (just-in-time): created on first SSO sign-in, sparse profile
- SCIM: created via directory sync, full profile with employee fields
- Pattern: detect via user.metadata.provisioning_source or absence of SCIM fields
- NEVER assume all WorkOS users have the same fields populated

## Hard rules
- NEVER hardcode connection_id or organization_id in source files
- NEVER bypass getSession() to access the AuthKit cookie
- NEVER skip webhook signature verification (signs all data is trusted)
- NEVER store WorkOS sensitive PII in your own DB without a clear retention policy
- ALWAYS log impersonator separately when present in the session
- ALWAYS scope database queries by organizationId, never by user_id alone

Four rules here prevent the majority of production failures Claude generates without them.

The getSession() rule is the most consequential for security and correctness. AuthKit sessions are not plain JWTs; they are encrypted cookies that the AuthKit SDK rotates, refreshes, and revokes on demand. Reading the cookie directly bypasses the refresh logic and produces stale sessions that pass validation locally but fail when the underlying refresh token has been revoked (for example, when an admin removed the user from the IdP). Claude defaults to cookies().get('workos-session') because it looks like a normal Next.js cookie read. The CLAUDE.md rule prevents this.

The organization scoping rule prevents tenant data leaks. A B2B SaaS application has many organizations sharing one application instance. A user belongs to one or more organizations and switches between them at sign-in time. The session carries the active organizationId. Every database query must include WHERE organization_id = $1 with that value. Claude often writes queries that filter by user_id alone, which is enough for a single-tenant app but leaks data across orgs in a B2B one. The CLAUDE.md rule forces the org filter on every read and write.

The connection_id vs organization_id rule prevents a subtle architecture mistake. Beginners (and Claude) sometimes hardcode a single SSO connection_id in the login redirect because that is what the WorkOS quickstart shows. This works for the first customer with that exact IdP configuration but fails for the second customer who uses a different SSO provider. The right pattern is to route by organizationId, which WorkOS resolves to the appropriate connection_id server-side.

Installation and client setup

Install the server SDK and AuthKit framework helper:

npm install @workos-inc/node @workos-inc/authkit-nextjs

Add the credentials to .env.local:

WORKOS_API_KEY=sk_test_your_api_key
WORKOS_CLIENT_ID=client_your_client_id
WORKOS_COOKIE_PASSWORD=at_least_32_chars_random_string_here
WORKOS_REDIRECT_URI=http://localhost:3000/auth/callback

The WORKOS_COOKIE_PASSWORD is the encryption key for the AuthKit session cookie. It must be at least 32 characters of random data. Rotating it invalidates all current sessions.

Create the singleton WorkOS client:

// src/lib/workos.ts
import { WorkOS } from "@workos-inc/node";

if (!process.env.WORKOS_API_KEY) {
  throw new Error("WORKOS_API_KEY is not defined");
}

export const workos = new WorkOS(process.env.WORKOS_API_KEY, {
  clientId: process.env.WORKOS_CLIENT_ID,
});

Add the AuthKit middleware that protects routes and refreshes sessions on every request:

// src/middleware.ts
import { authkitMiddleware } from "@workos-inc/authkit-nextjs";

export default authkitMiddleware({
  middlewareAuth: {
    enabled: true,
    unauthenticatedPaths: [
      "/",
      "/pricing",
      "/blog/(.*)",
      "/auth/sign-in",
      "/auth/callback",
      "/api/webhooks/workos",
    ],
  },
});

export const config = {
  matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\.svg).*)"],
};

Two things matter. The unauthenticatedPaths array lists the routes that should be public; everything else requires a valid session. The webhook endpoint must be in this list because WorkOS calls it without a user session. The middleware handles cookie refresh transparently. After the middleware runs, downstream route handlers can call getSession() and trust the result.

getSession() in route handlers

The session retrieval pattern that route handlers should follow:

// src/app/api/projects/route.ts
import { NextRequest, NextResponse } from "next/server";
import { getSession } from "@workos-inc/authkit-nextjs";
import { db } from "@/lib/db";

export async function GET(req: NextRequest) {
  const session = await getSession();
  if (!session?.user || !session.organizationId) {
    return NextResponse.json({ error: "unauthenticated" }, { status: 401 });
  }

  const projects = await db.projects.findMany({
    where: { organizationId: session.organizationId },
  });

  return NextResponse.json({ projects });
}

export async function POST(req: NextRequest) {
  const session = await getSession();
  if (!session?.user || !session.organizationId) {
    return NextResponse.json({ error: "unauthenticated" }, { status: 401 });
  }

  const body = await req.json();

  const project = await db.projects.create({
    data: {
      name: body.name,
      organizationId: session.organizationId,
      createdBy: session.user.id,
    },
  });

  return NextResponse.json({ project });
}

Notice the organizationId appears in both reads and writes. The findMany filters to that organization. The create stamps the new row with that organization. A different organization signing in cannot see or modify these projects, enforced by the query layer not by application logic that could be bypassed.

Add a session retrieval section to CLAUDE.md:

## Session retrieval in route handlers

const session = await getSession();
if (!session?.user || !session.organizationId) {
  return 401;
}

// Then EVERY DB query MUST filter by session.organizationId
const data = await db.collection.findMany({
  where: { organizationId: session.organizationId }
});

For writes:
await db.collection.create({
  data: { ...input, organizationId: session.organizationId }
});

For impersonator handling:
if (session.impersonator) {
  await audit.log({ ... actorId: session.impersonator.email });
}

The impersonator field is set when an admin uses WorkOS impersonation to sign in as another user (a common support workflow). The session is for the impersonated user, but the actor is the admin. Audit logs should record both. Claude omits this distinction without an explicit rule because the SDK does not surface it prominently.

AuthKit sign-in flow

The sign-in page redirects to the AuthKit hosted experience:

// src/app/auth/sign-in/page.tsx
import { redirect } from "next/navigation";
import { getSignInUrl } from "@workos-inc/authkit-nextjs";

export default async function SignInPage() {
  const url = await getSignInUrl();
  redirect(url);
}

The callback endpoint receives the auth code, exchanges it for a session, and sets the cookie:

// src/app/auth/callback/route.ts
import { handleAuth } from "@workos-inc/authkit-nextjs";

export const GET = handleAuth({
  returnPathname: "/dashboard",
});

The handleAuth helper handles the full exchange. After the callback, the user is signed in and any subsequent request hits middleware that injects the session. Claude sometimes tries to implement the exchange manually using workos.userManagement.authenticateWithCode, which is a lower-level API that does not set the AuthKit cookie. The result is a "successful" callback that produces a logged-out experience on the next page.

For an organization-specific sign-in (where you want to skip the org selection step and go straight to a known organization's SSO):

const url = await getSignInUrl({
  organizationId: "org_01HXY...",
});
redirect(url);

This pattern is common for customer-specific subdomains (acme.yourapp.com) where the organization is implied by the host. Claude often hardcodes the connection_id instead of the organizationId, which works for the initial customer but fails when a second customer with a different SSO provider needs to sign in through the same subdomain pattern.

For broader SSO patterns alongside other auth providers, Claude Code with Clerk covers an alternative auth approach for direct comparison.

SCIM directory sync webhooks

WorkOS Directory Sync provisions users from the customer's identity provider (Okta, Azure AD, Google Workspace). When users are added, updated, or removed in the IdP, WorkOS fires a webhook to your application.

The webhook endpoint:

// src/app/api/webhooks/workos/route.ts
import { NextRequest, NextResponse } from "next/server";
import { workos } from "@/lib/workos";
import { db } from "@/lib/db";

export async function POST(req: NextRequest) {
  const sig = req.headers.get("workos-signature");
  if (!sig) {
    return NextResponse.json({ error: "missing signature" }, { status: 400 });
  }

  const rawBody = await req.text();

  let event;
  try {
    event = await workos.webhooks.constructEvent({
      payload: rawBody,
      sigHeader: sig,
      secret: process.env.WORKOS_WEBHOOK_SECRET!,
    });
  } catch {
    return NextResponse.json({ error: "invalid signature" }, { status: 400 });
  }

  switch (event.event) {
    case "dsync.user.created":
      await provisionUser(event.data);
      break;

    case "dsync.user.updated":
      await updateUser(event.data);
      break;

    case "dsync.user.deleted":
      await deprovisionUser(event.data);
      break;

    case "dsync.group.user_added":
      await addUserToGroup(event.data);
      break;

    case "dsync.group.user_removed":
      await removeUserFromGroup(event.data);
      break;

    default:
      break;
  }

  return NextResponse.json({ ok: true });
}

async function provisionUser(payload: any) {
  await db.users.upsert({
    where: { workosId: payload.id },
    update: {
      email: payload.emails[0]?.value,
      name: `${payload.first_name} ${payload.last_name}`.trim(),
      organizationId: payload.directory_id,
      provisioningSource: "scim",
    },
    create: {
      workosId: payload.id,
      email: payload.emails[0]?.value,
      name: `${payload.first_name} ${payload.last_name}`.trim(),
      organizationId: payload.directory_id,
      provisioningSource: "scim",
    },
  });
}

async function updateUser(payload: any) { /* same shape as provisionUser */ }
async function deprovisionUser(payload: any) { /* soft-delete or hard-delete */ }
async function addUserToGroup(payload: any) { /* update memberships */ }
async function removeUserFromGroup(payload: any) { /* update memberships */ }

Three things matter.

The signature verification through workos.webhooks.constructEvent is non-negotiable. Without it, anyone can post arbitrary JSON to your webhook endpoint and provision users with whatever data they choose. Claude sometimes skips this check or implements a partial version that does not verify the signature properly.

The upsert pattern (where workosId, update + create) handles the idempotency requirement. WorkOS webhook delivery is at-least-once. The same event may arrive twice if the network times out on the first delivery. Idempotent handlers produce the same final state regardless of duplicates.

The provisioningSource: "scim" field separates SCIM-provisioned users from JIT-provisioned users. SCIM users have full directory profiles (manager, department, employee ID, full name). JIT users have only what their IdP provides at sign-in time. Application code that needs the full profile should check the source first.

Add a webhook section to CLAUDE.md:

## Webhooks

- Endpoint: src/app/api/webhooks/workos/route.ts
- Verify signature with workos.webhooks.constructEvent (NEVER skip)
- WORKOS_WEBHOOK_SECRET in .env.local
- Handle: dsync.user.{created, updated, deleted}, dsync.group.user_{added, removed}
- ALL handlers MUST be idempotent (upsert pattern, never plain create)
- Return 200 for valid events (even if no action taken)
- Return 400 only for invalid signature
- Include the webhook endpoint in unauthenticatedPaths in middleware

Multi-tenant database queries

Every database table that holds tenant data carries an organization_id column. Every query that touches it filters on the session's organizationId. This is the rule, with no exceptions for "admin" tools or "internal" queries.

// src/lib/projects.ts
import { db } from "./db";

export async function listProjects(organizationId: string) {
  return db.projects.findMany({
    where: { organizationId },
    orderBy: { createdAt: "desc" },
  });
}

export async function getProject(organizationId: string, projectId: string) {
  return db.projects.findFirst({
    where: { id: projectId, organizationId },
  });
}

export async function createProject(
  organizationId: string,
  userId: string,
  name: string
) {
  return db.projects.create({
    data: { name, organizationId, createdBy: userId },
  });
}

export async function deleteProject(organizationId: string, projectId: string) {
  return db.projects.delete({
    where: { id: projectId, organizationId },
  });
}

The pattern is uniform across reads, writes, and deletes. The function signatures explicitly take organizationId as the first parameter. There is no overload that skips it. This shape makes the security boundary visible at every call site. Claude often generates getProject(projectId) without the organization filter, on the assumption that the project ID is globally unique. Project IDs are unique but the absence of the filter means a user from organization A who guesses or scrapes a project ID from organization B can read it. The CLAUDE.md rule prevents this entire class of bug.

Common Claude Code mistakes with WorkOS

Six patterns Claude generates incorrectly without CLAUDE.md constraints, with the correct replacement for each.

1. Direct cookie read

Claude generates: const session = JSON.parse(cookies().get('workos-session')?.value ?? '{}').

Correct pattern: const session = await getSession() from @workos-inc/authkit-nextjs.

2. Hardcoded connection_id

Claude generates: getSignInUrl({ connectionId: 'conn_01HXY...' }) for a specific SSO provider.

Correct pattern: getSignInUrl({ organizationId: 'org_01HXY...' }) or no options (AuthKit selects the right SSO at sign-in).

3. Missing organization filter

Claude generates: db.projects.findFirst({ where: { id: projectId } }).

Correct pattern: db.projects.findFirst({ where: { id: projectId, organizationId: session.organizationId } }).

4. Skipping webhook signature verification

Claude generates: const event = JSON.parse(await req.text()); switch (event.event) { ... }.

Correct pattern: const event = await workos.webhooks.constructEvent({ payload, sigHeader, secret }).

5. Same shape assumption for SCIM and JIT users

Claude generates code that reads user.metadata.department without checking whether the user came from a SCIM directory.

Correct pattern: detect provisioning source first; only read SCIM-specific fields when the source is "scim".

6. Manual code exchange

Claude generates: await workos.userManagement.authenticateWithCode({ code }) in the callback handler and tries to set cookies manually.

Correct pattern: export const GET = handleAuth({ returnPathname: "/dashboard" }) in the callback route file.

Add a common mistakes section to CLAUDE.md with these six pairs. Claude benefits from explicit before/after comparisons because it can match the pattern it would otherwise generate to the corrected form.

Permission hooks for WorkOS scripts

A WorkOS project accumulates scripts: directory sync triggers, user backfills, impersonation tokens, organization provisioning. Some are read-only. Some create or revoke real organization access. Permission hooks gate the destructive ones.

In .claude/settings.local.json:

{
  "permissions": {
    "allow": [
      "Bash(npx tsx scripts/list-orgs.ts*)",
      "Bash(npx tsx scripts/inspect-user.ts*)",
      "Bash(curl -X GET https://api.workos.com/organizations*)"
    ],
    "deny": [
      "Bash(npx tsx scripts/provision-org.ts*)",
      "Bash(npx tsx scripts/revoke-org.ts*)",
      "Bash(npx tsx scripts/impersonate.ts*)",
      "Bash(npx tsx scripts/bulk-deprovision.ts*)"
    ]
  }
}

Listing organizations and inspecting users are safe. Provisioning new organizations, revoking access, impersonating users, and bulk deprovisions require explicit confirmation. The deny list forces Claude to surface those operations as prompts rather than running them as part of an automated workflow.

Building enterprise auth that doesn't leak across tenants

The WorkOS CLAUDE.md in this guide produces integrations where every session is retrieved through getSession() with automatic refresh, every database query is scoped to the active organization, every webhook signature is verified before any side effect, every sign-in routes by organization not connection, and every SCIM user is distinguished from every JIT user.

The underlying principle is the same as any auth integration with Claude Code. WorkOS without a CLAUDE.md produces code that works for a single tenant in development and ships data leaks the moment a second customer signs up. The CLAUDE.md template removes each failure mode by making the correct pattern the only pattern Claude can generate.

For the broader auth stack, Claude Code with NextAuth covers a self-hosted alternative for direct comparison, and Claudify includes a WorkOS-specific CLAUDE.md template with the session retrieval pattern, organization scoping rules, SCIM webhook handling, and all six common-mistake rules pre-configured.

Get Claudify. A complete Claude Code operating system with enterprise auth templates that match how production B2B SaaS teams actually ship.

More like this

Ready to upgrade your Claude Code setup?

Get Claudify
Featured on Dofollow.Tools AI Toolz Dir Claudify - Featured on Startup Fame