---
title: "Authentication"
description: "Authentication manages user identity, session lifecycle, and enterprise Single Sign-On (SSO) across workspaces. It combines standard credential and OAuth providers with BoxyHQ Jackson for SAML and ..."
last_updated: "2026-10-12T07:00:10.095215+00:00"
canonical_url: "https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/core-architecture/authentication"
---

<details>
<summary>Relevant source files</summary>

The following files were used as context for generating this wiki page:

- [apps/web/lib/auth/options.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/auth/options.ts)
- [apps/web/app/ee/api/scim/v2.0/...directory/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/scim/v2.0/%5B...directory%5D/route.ts)
- [apps/web/app/app.dub.co/dashboard/slug/ee/settings/security/scim.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/settings/security/scim.tsx)
- [apps/web/ui/modals/scim-modal.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/ui/modals/scim-modal.tsx)
- [apps/web/app/api/workspaces/idOrSlug/saml/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/workspaces/%5BidOrSlug%5D/saml/route.ts)
- [apps/web/app/api/workspaces/idOrSlug/scim/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/workspaces/%5BidOrSlug%5D/scim/route.ts)
- [apps/web/app/ee/api/auth/saml/verify/route.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/auth/saml/verify/route.tsx)
- [apps/web/lib/jackson.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/jackson.ts)
- [apps/web/app/app.dub.co/auth/auth/saml/form.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/app.dub.co/(auth)/auth/saml/form.tsx)
- [apps/web/app/ee/api/auth/saml/authorize/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/auth/saml/authorize/route.ts)
- [apps/web/app/api/oauth/userinfo/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/oauth/userinfo/route.ts)
- [apps/web/app/ee/api/admin/impersonate/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/admin/impersonate/route.ts)
- [apps/web/app/ee/partners.dub.co/auth-login-register/generic/login/page.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/partners.dub.co/(auth-login-register)/(generic)/login/page.tsx)
- [apps/web/app/app.dub.co/dashboard/slug/ee/settings/security/page-client.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/settings/security/page-client.tsx)
- [apps/web/app/app.dub.co/dashboard/slug/ee/settings/security/saml.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/settings/security/saml.tsx)
- [apps/web/app/ee/api/auth/saml/callback/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/auth/saml/callback/route.ts)
- [apps/web/ui/auth/login/sso-sign-in.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/ui/auth/login/sso-sign-in.tsx)
- [apps/web/app/ee/api/auth/saml/userinfo/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/auth/saml/userinfo/route.ts)
- [apps/web/app/ee/api/auth/saml/token/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/auth/saml/token/route.ts)
- [apps/web/lib/swr/use-scim.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/swr/use-scim.ts)
- [apps/web/app/app.dub.co/auth/auth/saml/page.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/app.dub.co/(auth)/auth/saml/page.tsx)
- [apps/web/ui/modals/remove-scim-modal.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/ui/modals/remove-scim-modal.tsx)
- [apps/web/app/api/auth/...nextauth/route.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/auth/%5B...nextauth%5D/route.tsx)
- [apps/web/lib/auth/utils.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/auth/utils.ts)
- [apps/web/app/ee/partners.dub.co/auth-login-register/generic/login/sso-login-button.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/partners.dub.co/(auth-login-register)/(generic)/login/sso-login-button.tsx)
- [apps/web/lib/auth/workspace.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/auth/workspace.ts)
- [apps/web/lib/auth/session.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/auth/session.ts)
- [apps/web/lib/actions/auth/throw-if-authenticated.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/actions/auth/throw-if-authenticated.ts)
- [packages/utils/src/constants/saml.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/packages/utils/src/constants/saml.ts)
- [apps/web/lib/api/workspaces/is-saml-enforced-for-email-domain.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/api/workspaces/is-saml-enforced-for-email-domain.ts)
</details>

## Overview

Authentication manages user identity, session lifecycle, and enterprise Single Sign-On (SSO) across workspaces. It combines standard credential and OAuth providers with BoxyHQ Jackson for SAML and SCIM protocols, enabling enterprise tenants to enforce domain-level security policies, synchronize user directories, and secure API and web routes.
Sources: [apps/web/lib/auth/options.ts:95-181](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/auth/options.ts#L95-L181), [apps/web/lib/jackson.ts:42-58](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/jackson.ts#L42-L58)

## NextAuth Configuration and Credentials

### API Route Handler and Authentication Options

The public authentication API endpoint is hosted via a Next.js App Router route handler at `apps/web/app/api/auth/[...nextauth]/route.tsx`. It exports GET and POST handlers initialized by passing `authOptions` from `apps/web/lib/auth/options.ts` to the `NextAuth` function.
Sources: [apps/web/app/api/auth/...nextauth/route.tsx:1-6](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/auth/%5B...nextauth%5D/route.tsx#L1-L6)

The `authOptions` configuration defines multiple authentication mechanisms, including an `EmailProvider` with custom verification request sending, `GoogleProvider`, `GithubProvider`, a BoxyHQ `saml` provider, a `saml-idp` credentials provider, and a standard `credentials` provider for email and password authentication.
Sources: [apps/web/lib/auth/options.ts:95-263](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/auth/options.ts#L95-L263)

| Provider ID | Provider Name | Type | Key Configuration |
| :--- | :--- | :--- | :--- |
| `email` | Email | email | Rate-limited login [link dispatch](https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/link-attribution/link-dispatch) (`RATELIMIT_POLICIES.loginLinkSend`) |
| `google` | Google | oauth | `allowDangerousEmailAccountLinking: true` |
| `github` | GitHub | oauth | `issuer: "https://github.com/login/oauth"`, `allowDangerousEmailAccountLinking: true` |
| `saml` | BoxyHQ | oauth | `checks: ["pkce", "state"]`, grant type `authorization_code` |
| `saml-idp` | IdP Login | credentials | Custom `authorize` handling Jackson OAuth token and userInfo exchange |
| `credentials` | Dub.co | credentials | Email and password validation with SSO enforcement and brute-force protection |

Sources: [apps/web/lib/auth/options.ts:97-263](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/auth/options.ts#L97-L263)

### Credential Validation and Brute-Force Safeguards

The standard credentials provider processes `email` and `password` inputs through the `authorize` callback. It enforces rate limiting on the lowercased email address, checks whether SAML Single Sign-On is mandated for the user's email domain, and queries the database for the user record and password hash.
Sources: [apps/web/lib/auth/options.ts:256-288](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/auth/options.ts#L256-L288)

> [!CAUTION]
> If a user's email domain has SAML enforcement enabled via `isSamlEnforcedForEmailDomain`, the credentials authorize function immediately throws a `require-saml-sso` error, blocking password-based authentication for enterprise tenants.
> Sources: [apps/web/lib/auth/options.ts:281-285](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/auth/options.ts#L281-L285)

When a password mismatch occurs, the login attempt counter increments for the user record. If failed attempts exceed the designated threshold, an `exceeded-login-attempts` error is thrown. Authentication further requires that the user's email address is verified (`emailVerified`).
Sources: [apps/web/lib/auth/options.ts:313-327](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/auth/options.ts#L313-L327)

### Prisma Adapter Token Verification

Token persistence and verification handle lifecycle management for authentication tokens and verification codes via Prisma database queries. When a verification token is consumed, the implementation attempts to delete the record matching the composite key `identifier_token`.
Sources: [apps/web/lib/auth/options.ts:75-77](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/auth/options.ts#L75-L77)

> [!NOTE]
> When Prisma throws error code `P2025` during token deletion (indicating the record does not exist or has already expired), the verification handler gracefully catches the exception and returns `null` rather than failing the request.
> Sources: [apps/web/lib/auth/options.ts:84-90](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/auth/options.ts#L84-L90)

If the deleted `verificationToken` has the `isAdminImpersonation` flag set to true, the handler invokes `markAdminImpersonation(identifier)` to record the audit state before returning the token object.
Sources: [apps/web/lib/auth/options.ts:79-83](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/auth/options.ts#L79-L83)

## Session Context and Workspace Authorization

### Route Middleware Wrappers

Server session extraction and bearer token parsing are managed through helper utilities Authentication. The `getSession` function utilizes NextAuth's `getServerSession` combined with `authOptions` to retrieve the active session, resolving to a `Session` type containing user identifiers, names, emails, avatar images, machine user flags, default workspaces, and default partner IDs.
Sources: [apps/web/lib/auth/utils.ts:1-21](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/auth/utils.ts#L1-L21)

```typescript
export interface Session {
  user: {
    id: string;
    name: string;
    email: string;
    image?: string;
    isMachine: boolean;
    defaultWorkspace?: string;
    defaultPartnerId?: string;
  };
}

export const getSession = async () => {
  return getServerSession(authOptions) as Promise<Session>;
};
```
Sources: [apps/web/lib/auth/utils.ts:7-21](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/auth/utils.ts#L7-L21)

When API requests provide external credentials, `getAuthTokenOrThrow` inspects the incoming request headers for an `Authorization` header. If the header is absent, it throws a `DubApiError` with a `bad_request` code. Otherwise, it strips the specified authorization prefix (defaulting to `Bearer`) to return the raw token string.
Sources: [apps/web/lib/auth/utils.ts:23-38](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/auth/utils.ts#L23-L38)

```typescript
export const getAuthTokenOrThrow = (
  req: Request | NextRequest,
  type: "Bearer" | "Basic" = "Bearer",
) => {
  const authorizationHeader = req.headers.get("Authorization");

  if (!authorizationHeader) {
    throw new DubApiError({
      code: "bad_request",
      message:
        "Misconfigured authorization header. Did you forget to add 'Bearer '? Learn more: https://d.to/auth",
    });
  }

  return authorizationHeader.replace(`${type} `, "");
};
```
Sources: [apps/web/lib/auth/utils.ts:23-38](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/auth/utils.ts#L23-L38)

Protected API endpoints use higher-order wrapper functions to enforce authentication, rate limits, plan tiers, and role permissions before executing handler logic. The `withSession` wrapper handles lightweight session or bearer token validation, applying Upstash rate limiting at 60 requests per minute for token-authenticated requests, populating rate limit headers (`X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`, `Retry-After`), and updating token last-used timestamps asynchronously via Vercel `waitUntil`.
Sources: [apps/web/lib/auth/session.ts:25-136](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/auth/session.ts#L25-L136)

```typescript
export const withSession = (handler: WithSessionHandler) =>
  withAxiom(
    async (
      req,
      { params: initialParams }: { params: Promise<Record<string, string>> },
    ) => {
      const params = (await initialParams) || {};
      let requestHeaders = await headers();
      let responseHeaders = new Headers();

      try {
        let session: Session | undefined;

        const authorizationHeader = requestHeaders.get("Authorization");
        if (authorizationHeader) {
          if (!authorizationHeader.startsWith("Bearer ")) {
            throw new DubApiError({
              code: "bad_request",
              message:
                "Misconfigured authorization header. Did you forget to add 'Bearer '? Learn more: https://d.to/auth",
            });
          }
          const apiKey = authorizationHeader.replace("Bearer ", "");

          const hashedKey = await hashToken(apiKey);

          const user = await prisma.user.findFirst({
            where: {
              tokens: {
                some: {
                  hashedKey,
                },
              },
            },
            select: {
              id: true,
              name: true,
              email: true,
              isMachine: true,
            },
          });
          if (!user) {
            throw new DubApiError({
              code: "unauthorized",
              message: "Unauthorized: Invalid API key.",
            });
          }

          const { success, limit, reset, remaining } = await ratelimit(
            60,
            "1 m",
          ).limit(apiKey);

          responseHeaders.set("Retry-After", reset.toString());
          responseHeaders.set("X-RateLimit-Limit", limit.toString());
          responseHeaders.set("X-RateLimit-Remaining", remaining.toString());
          responseHeaders.set("X-RateLimit-Reset", reset.toString());

          if (!success) {
            throw new DubApiError({
              code: "rate_limit_exceeded",
              message: "Too many requests.",
            });
          }
          waitUntil(
            (async () => {
              try {
                // update last used time for the token (only once every minute)
                const { success } = await ratelimit(1, "1 m").limit(
                  `last-used-${hashedKey}`,
                );

                if (success) {
                  await prisma.token.update({
                    where: {
                      hashedKey,
                    },
                    data: {
                      lastUsed: new Date(),
                    },
```
Sources: [apps/web/lib/auth/session.ts:25-136](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/auth/session.ts#L25-L136)

For workspace-scoped routes, `withWorkspace` provides rigorous multi-tenant verification. It processes standard session cookies or Bearer API keys (supporting both standard tokens and restricted `dub_` tokens with granular scopes), resolves workspace IDs or slugs, enforces plan restrictions, validates role permissions, checks beta feature flags, and captures request telemetry logs.
Sources: [apps/web/lib/auth/workspace.ts:60-555](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/auth/workspace.ts#L60-L555)

```typescript
export const withWorkspace = (
  handler: WithWorkspaceHandler,
  {
    requiredPlan = ["free", "pro", "business", "advanced", "enterprise"], // if the action needs a specific plan
    requiredPermissions = [],
    requiredRoles = [],
    featureFlag, // if the action needs a specific feature flag
  }: {
    requiredPlan?: Array<PlanProps>;
    requiredPermissions?: PermissionAction[];
    requiredRoles?: WorkspaceRole[];
    featureFlag?: BetaFeatures;
  } = {},
) => {
```
Sources: [apps/web/lib/auth/workspace.ts:60-73](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/auth/workspace.ts#L60-L73)

> [!NOTE]
> Restricted tokens starting with `dub_` query the `RestrictedToken` database model and filter permissions by mapping token scopes against the user's role capabilities. Empty or null scopes fail closed to prevent privilege escalation.
> Sources: [apps/web/lib/auth/workspace.ts:113-203](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/auth/workspace.ts#L113-L203), [apps/web/lib/auth/workspace.ts:411-418](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/auth/workspace.ts#L411-L418)

### Session and API Rate Limiting

The middleware layers apply distinct rate limiting strategies depending on whether the caller authenticates via user session or API key, and whether the route targets standard endpoints or high-frequency analytics paths.

| Rate Limit Context | Identifier Key Pattern | Request Limit | Time Interval |
| :--- | :--- | :--- | :--- |
| API Session Requests | `workspace:ratelimit:{userId}` | 600 requests | 1 minute |
| Analytics API Sessions | `workspace:ratelimit:{userId}` | 12 requests | 1 second |
| Workspace API Keys | `workspace:ratelimit:{hashedKey}` | Plan-dependent (`getRatelimitForPlan`) | 1 minute |
| Analytics API Keys | `workspace:ratelimit:{hashedKey}` | Plan-dependent (`getRatelimitForPlan`) | 1 second |
| Token Last-Used Tracker | `last-used-{hashedKey}` | 1 request | 1 minute |

Sources: [apps/web/lib/auth/workspace.ts:27-36](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/auth/workspace.ts#L27-L36), [apps/web/lib/auth/workspace.ts:231-245](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/auth/workspace.ts#L231-L245), [apps/web/lib/auth/workspace.ts:269-272](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/auth/workspace.ts#L269-L272), [apps/web/lib/auth/workspace.ts:314-322](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/auth/workspace.ts#L314-L322)

> [!WARNING]
> When a workspace cannot be found or a user lacks membership during authorization checks, the `workspace` reference is explicitly cleared to `undefined` before throwing errors. This prevents unauthorized lookup failures from being incorrectly logged or attributed to the target workspace in Axiom and API [audit logs](https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/analytics-pipeline/audit-logs).
> Sources: [apps/web/lib/auth/workspace.ts:355-382](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/auth/workspace.ts#L355-L382)

## BoxyHQ Jackson SAML Integration

### Jackson Controller Initialization and Configuration

BoxyHQ Jackson is initialized as a singleton across the application lifecycle through the `jackson()` function, managing `apiController`, `oauthController`, and `directorySyncController` instances. The configuration defines the external URL based on the runtime environment (`https://api.dub.co` in production or `APP_DOMAIN_WITH_NGROK` otherwise), uses `https://saml.dub.co` as the SAML audience, and connects to PlanetScale using MySQL with SSL verification disabled. It enables identity provider (IdP) initiated SSO, sets custom SCIM and SAML paths depending on the environment, and uses `NEXTAUTH_SECRET` as the client secret verifier.

```typescript
import type {
  IConnectionAPIController,
  IDirectorySyncController,
  IOAuthController,
  JacksonOption,
} from "@boxyhq/saml-jackson";
import samlJackson from "@boxyhq/saml-jackson";
import { APP_DOMAIN_WITH_NGROK } from "@dub/utils";

export const samlAudience = "https://saml.dub.co";

const opts: JacksonOption = {
  externalUrl:
    process.env.NODE_ENV === "production"
      ? "https://api.dub.co"
      : APP_DOMAIN_WITH_NGROK,
  samlPath:
    process.env.NODE_ENV === "production"
      ? "/auth/saml/callback"
      : "/api/auth/saml/callback",
  samlAudience,
  db: {
    engine: "planetscale",
    type: "mysql",
    url: process.env.DATABASE_URL as string,
    ssl: {
      rejectUnauthorized: false,
    },
  },
  idpEnabled: true, // to allow folks to SSO directly from their IDP
  scimPath:
    process.env.NODE_ENV === "production" ? "/scim/v2.0" : "/api/scim/v2.0", // custom SCIM endpoint
  clientSecretVerifier: process.env.NEXTAUTH_SECRET as string,
};

```
Sources: [apps/web/lib/jackson.ts:1-35](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/jackson.ts#L1-L35)

```typescript
export async function jackson() {
  if (
    !globalThis.apiController ||
    !globalThis.oauthController ||
    !globalThis.directorySyncController
  ) {
    const ret = await samlJackson(opts);
    globalThis.apiController = ret.connectionAPIController;
    globalThis.oauthController = ret.oauthController;
    globalThis.directorySyncController = ret.directorySyncController;
  }

  return {
    apiController: globalThis.apiController,
    oauthController: globalThis.oauthController,
    directorySyncController: globalThis.directorySyncController,
  };
```
Sources: [apps/web/lib/jackson.ts:42-58](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/jackson.ts#L42-L58)

### Authorization, Verification, and Token Exchange Endpoints

The SAML authentication pipeline exposes several API routes for handling SSO requests, form authorization, response callbacks, and token generation:

- **Authorize (`/api/auth/saml/authorize`)**: Accepts GET parameters or POST JSON payloads, passing them to `oauthController.authorize(requestParams)` to issue an HTTP 302 redirect or return an HTML authorization form.
- **Callback (`/api/auth/saml/callback`)**: Processes incoming `POST` requests with form data containing `RelayState` and `SAMLResponse`, validating them through `oauthController.samlResponse()` before redirecting the user.
- **Token (`/api/auth/saml/token`)**: Parses form-encoded request bodies and invokes `oauthController.token(body)` to exchange authorization codes for access tokens.
- **Verify (`/api/auth/saml/verify`)**: Validates workspace slugs and enforces rate limiting policies via Upstash before querying `apiController.getConnections()` to ensure an active SSO connection exists for the tenant.
- **User Info (`/api/auth/saml/userinfo`)**: Extracts and verifies Bearer tokens from the `Authorization` header to retrieve authenticated user attributes via `oauthController.userInfo()`.

```typescript
import { jackson } from "@/lib/jackson";
import { getSearchParams } from "@dub/utils";
import { NextResponse } from "next/server";

const handler = async (req: Request) => {
  const { oauthController } = await jackson();

  const requestParams =
    req.method === "GET" ? getSearchParams(req.url) : await req.json();

  const { redirect_url, authorize_form } =
    await oauthController.authorize(requestParams);

  if (redirect_url) {
    return NextResponse.redirect(redirect_url, {
      status: 302,
    });
  } else {
    return new Response(authorize_form, {
      headers: {
        "Content-Type": "text/html; charset=utf-8",
      },
    });
  }
};
```
Sources: [apps/web/app/ee/api/auth/saml/authorize/route.ts:1-25](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/auth/saml/authorize/route.ts#L1-L25)

```typescript
import { jackson } from "@/lib/jackson";
import { NextResponse } from "next/server";

export async function POST(req: Request) {
  const { oauthController } = await jackson();

  const formData = await req.formData();

  const RelayState = formData.get("RelayState") || "";
  const SAMLResponse = formData.get("SAMLResponse") || "";

  const { redirect_url } = await oauthController.samlResponse({
    RelayState: RelayState as string,
    SAMLResponse: SAMLResponse as string,
  });

  if (!redirect_url) {
    return new Response("No redirect URL found.", {
      status: 400,
    });
  }

  return NextResponse.redirect(redirect_url, {
    status: 302,
  });
}
```
Sources: [apps/web/app/ee/api/auth/saml/callback/route.ts:1-26](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/auth/saml/callback/route.ts#L1-L26)

```typescript
import { jackson } from "@/lib/jackson";
import * as jose from "jose";
import { NextResponse } from "next/server";
import * as dummy from "openid-client";

export async function POST(req: Request) {
  console.log("token route");

  // Need these imports to fix import errors with jackson
  // https://github.com/ory/polis/blob/main/pages/api/import-hack.ts
  const unused = dummy; // eslint-disable-line @typescript-eslint/no-unused-vars
  const unused2 = jose; // eslint-disable-line @typescript-eslint/no-unused-vars

  const { oauthController } = await jackson();

  const formData = await req.formData();
  const body = Object.fromEntries(formData.entries());

  const token = await oauthController.token(body as any);

  return NextResponse.json(token);
}
```
Sources: [apps/web/app/ee/api/auth/saml/token/route.ts:1-22](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/auth/saml/token/route.ts#L1-L22)

```tsx
export async function POST(req: Request) {
  const { apiController } = await jackson();

  const { slug } = await req.json();

  if (!slug) {
    return NextResponse.json(
      { error: "No workspace slug provided." },
      { status: 400 },
    );
  }

  try {
    await assertRateLimit({
      policy: RATELIMIT_POLICIES.samlVerify,
      identifier: await getIP(),
    });
  } catch (error) {
    if (error instanceof DubApiError && error.code === "rate_limit_exceeded") {
      return NextResponse.json(
        {
          error: error.message,
        },
        { status: 429 },
      );
    }

    throw error;
  }

  const workspace = await prisma.project.findUnique({
    where: { slug },
    select: { id: true },
  });

  if (!workspace) {
    return NextResponse.json(
      { error: "No SSO connection found for this workspace." },
      { status: 404 },
    );
  }

  const connections = await apiController.getConnections({
    tenant: workspace.id,
    product: "Dub",
  });

  if (!connections || connections.length === 0) {
    return NextResponse.json(
      { error: "No SSO connection found for this workspace." },
      { status: 404 },
    );
  }

  const data = {
    workspaceId: workspace.id,
  };

  return NextResponse.json({ data });
}
```
Sources: [apps/web/app/ee/api/auth/saml/verify/route.tsx:9-68](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/auth/saml/verify/route.tsx#L9-L68)

```typescript
import { jackson } from "@/lib/jackson";
import { NextResponse } from "next/server";

export async function GET(req: Request) {
  const { oauthController } = await jackson();

  const authHeader = req.headers.get("Authorization");

  if (!authHeader) {
    return new Response("Unauthorized", {
      status: 401,
    });
  }

  const token = authHeader.split(" ")[1];

  const user = await oauthController.userInfo(token);

  return NextResponse.json(user);
}
```
Sources: [apps/web/app/ee/api/auth/saml/userinfo/route.ts:1-20](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/auth/saml/userinfo/route.ts#L1-L20)

### Supported SAML and SCIM Providers

Dub supports major identity providers for enterprise single sign-on and directory provisioning through predefined configurations in the utility constants.

| Provider Name | SAML Identifier | SCIM Identifier | Saml Modal Copy | Scim URL Label | Scim Token Label |
| :--- | :--- | :--- | :--- | :--- | :--- |
| Okta | `okta` | `okta-scim-v2` | Metadata URL | SCIM 2.0 Base URL | OAuth Bearer Token |
| Entra ID (formerly Azure AD) | `azure` | `azure-scim-v2` | App Federation Metadata URL | Tenant URL | Secret Token |
| Google | `google` | `google` | XML Metadata File | SCIM 2.0 Base URL | OAuth Bearer Token |

Sources: [packages/utils/src/constants/saml.ts:1-38](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/packages/utils/src/constants/saml.ts#L1-L38)

> [!NOTE]
> The SAML verification endpoint (`/api/auth/saml/verify`) enforces strict rate limiting using the `RATELIMIT_POLICIES.samlVerify` policy evaluated against the client IP address. If the rate limit is exceeded, it returns a 429 status code with the error message provided by the rate limiter.
> Sources: [apps/web/app/(ee)/api/auth/saml/verify/route.tsx:22-37](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/auth/saml/verify/route.tsx#L22-L37)

## SAML Enforcement and Tenant Management

### Domain-Based Enforcement and Verification Rules

Workspace SAML enforcement relies on checking the user's email domain against registered enterprise configurations stored in the database. The helper function `isSamlEnforcedForEmailDomain` inspects incoming request headers for the host, extracts the email domain from the user's address, and verifies that generic email providers are ignored. It queries the `project` model using `ssoEmailDomain` to determine whether `ssoEnforcedAt` is populated, signaling that authentication must proceed through enterprise SSO.

```typescript
export const isSamlEnforcedForEmailDomain = async (email: string) => {
  const hostname = (await headers()).get("host");
  const emailDomain = email.split("@")[1].toLocaleLowerCase();

  if (
    !hostname ||
    !emailDomain ||
    !isAppHostname(hostname) ||
    isGenericEmail(email)
  ) {
    return false;
  }

  const workspace = await prisma.project.findUnique({
    where: {
      ssoEmailDomain: emailDomain,
    },
    select: {
      ssoEnforcedAt: true,
    },
  });

  if (workspace?.ssoEnforcedAt) {
    return true;
  }

  return false;
};
```
Sources: [apps/web/lib/api/workspaces/is-saml-enforced-for-email-domain.ts:7-34](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/api/workspaces/is-saml-enforced-for-email-domain.ts#L7-L34)

> [!WARNING]
> Generic email domains such as public webmail providers are automatically rejected by `isSamlEnforcedForEmailDomain` and when creating SAML connections, preventing organizations from enforcing workspace-wide SSO on public or shared email providers.
> Sources: [apps/web/app/api/workspaces/[idOrSlug]/saml/route.ts:63-69](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/workspaces/%5BidOrSlug%5D/saml/route.ts#L63-L69), [apps/web/lib/api/workspaces/is-saml-enforced-for-email-domain.ts:11-18](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/api/workspaces/is-saml-enforced-for-email-domain.ts#L11-L18)

### Workspace Connection CRUD Routes

Workspace SAML configurations are managed through dedicated API endpoints under `/api/workspaces/[idOrSlug]/saml`. The available HTTP methods, required permissions, and payload validation rules govern how enterprise connections are queried, created, and deleted.

| Method | Route Path | Required Permission | Required Plan | Payload / Query Schema | Description |
| :--- | :--- | :--- | :--- | :--- | :--- |
| `GET` | `/api/workspaces/[idOrSlug]/saml` | `workspaces.read` | None | None | Retrieves configured SAML connections for the workspace, audience issuer, and ACS callback URLs. |
| `POST` | `/api/workspaces/[idOrSlug]/saml` | `workspaces.write` | `enterprise` | `{ metadataUrl?: string, encodedRawMetadata?: string }` | Creates a new SAML connection via BoxyHQ Jackson and updates the project's `ssoEmailDomain`. |
| `DELETE` | `/api/workspaces/[idOrSlug]/saml` | `workspaces.write` | None | `{ clientID: string, clientSecret: string }` (query params) | Deletes matching SAML connections and resets `ssoEmailDomain` and `ssoEnforcedAt` to null. |

Sources: [apps/web/app/api/workspaces/idOrSlug/saml/route.ts:10-150](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/workspaces/%5BidOrSlug%5D/saml/route.ts#L10-L150)

```typescript
export const GET = withWorkspace(
  async ({ workspace }) => {
    const { apiController } = await jackson();

    const connections = await apiController.getConnections({
      tenant: workspace.id,
      product: "Dub",
    });

    const response = {
      connections,
      issuer: samlAudience,
      acs:
        process.env.NODE_ENV === "production"
          ? "https://api.dub.co/auth/saml/callback"
          : `${APP_DOMAIN_WITH_NGROK}/api/auth/saml/callback`,
    };

    return NextResponse.json(response);
  },
  {
    requiredPermissions: ["workspaces.read"],
  },
);
```
Sources: [apps/web/app/api/workspaces/idOrSlug/saml/route.ts:30-53](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/workspaces/%5BidOrSlug%5D/saml/route.ts#L30-L53)

```typescript
export const POST = withWorkspace(
  async ({ req, workspace, session }) => {
    const { metadataUrl, encodedRawMetadata } =
      createSAMLConnectionSchema.parse(await req.json());

    const ssoEmailDomain = session.user.email.split("@")[1].toLocaleLowerCase();

    if (isGenericEmail(ssoEmailDomain)) {
      throw new DubApiError({
        code: "bad_request",
        message:
          "SAML configuration requires you to be logged in with your organization’s work email.",
      });
    }

    const { apiController } = await jackson();

    const data = await apiController.createSAMLConnection({
      encodedRawMetadata: encodedRawMetadata!,
      metadataUrl: metadataUrl!,
      defaultRedirectUrl: `${process.env.NEXTAUTH_URL}/auth/saml`,
      redirectUrl: process.env.NEXTAUTH_URL as string,
      tenant: workspace.id,
      product: "Dub",
    });

    await prisma.project.update({
      where: {
        id: workspace.id,
      },
      data: {
        ssoEmailDomain,
      },
    });

    return NextResponse.json(data);
  },
  {
    requiredPermissions: ["workspaces.write"],
    requiredPlan: ["enterprise"],
  },
);
```
Sources: [apps/web/app/api/workspaces/idOrSlug/saml/route.ts:56-97](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/workspaces/%5BidOrSlug%5D/saml/route.ts#L56-L97)

```typescript
export const DELETE = withWorkspace(
  async ({ searchParams, workspace }) => {
    const { clientID, clientSecret } =
      deleteSAMLConnectionSchema.parse(searchParams);

    const { apiController } = await jackson();

    const connections = await apiController.getConnections({
      tenant: workspace.id,
      product: "Dub",
    });

    const connection = connections.find(
      (connection) => connection.clientID === clientID,
    );

    if (!connection) {
      throw new DubApiError({
        code: "not_found",
        message: "SAML connection not found",
      });
    }

    await apiController.deleteConnections({
      clientID: connection.clientID,
      clientSecret: connection.clientSecret,
    });

    await apiController.deleteConnections({
      clientID,
      clientSecret,
    });

    await prisma.project.update({
      where: {
        id: workspace.id,
      },
      data: {
        ssoEmailDomain: null,
        ssoEnforcedAt: null,
      },
    });

    return NextResponse.json({
      response: "Successfully removed SAML connection",
    });
  },
  {
    requiredPermissions: ["workspaces.write"],
  },
);
```
Sources: [apps/web/app/api/workspaces/idOrSlug/saml/route.ts:100-150](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/workspaces/%5BidOrSlug%5D/saml/route.ts#L100-L150)

### Login Redirection and Interface Components

Authentication flows rely on client-side components to handle sign-in triggers, workspace slug lookups, and identity provider callbacks. The `SSOSignIn` form prompts users for their workspace slug, verifies it against `/api/auth/saml/verify`, and initiates the NextAuth SAML sign-in flow with the resolved workspace tenant ID. Meanwhile, `SAMLForm` processes Identity Provider (IdP) initiated login callbacks by extracting authorization codes from search parameters and executing `signIn("saml-idp", { callbackUrl: "/", code })`.

```tsx
onSubmit={async (e) => {
  e.preventDefault();
  setClickedMethod("saml");
  fetch("/api/auth/saml/verify", {
    method: "POST",
    body: JSON.stringify({ slug: e.currentTarget.slug.value }),
  }).then(async (res) => {
    const { data, error } = await res.json();
    if (error) {
      toast.error(error);
      setClickedMethod(undefined);
      return;
    }
    setLastUsedAuthMethod("saml");
    await signIn("saml", undefined, {
      tenant: data.workspaceId,
      product: "Dub",
    });
  });
}}
```
Sources: [apps/web/ui/auth/login/sso-sign-in.tsx:24-43](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/ui/auth/login/sso-sign-in.tsx#L24-L43)

```tsx
export default function SAMLForm() {
  const searchParams = useSearchParams();

  useEffect(() => {
    const code = searchParams?.get("code");

    signIn("saml-idp", {
      callbackUrl: "/",
      code,
    });
  }, []);
```
Sources: [apps/web/app/app.dub.co/auth/auth/saml/form.tsx:8-18](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/app.dub.co/(auth)/auth/saml/form.tsx#L8-L18)

## SCIM Directory Provisioning Engine

### SCIM v2.0 Protocol Endpoint Routing

The SCIM v2.0 provisioning architecture relies on BoxyHQ Jackson combined with dynamic Next.js API route parameters under `apps/web/app/(ee)/api/scim/v2.0/[...directory]/route.ts`. The route handler extracts the `Authorization` header bearer token as `apiSecret`, parses query string filters, pagination parameters (`count`, `startIndex`), and maps incoming HTTP methods (`GET`, `POST`, `PUT`, `DELETE`) to BoxyHQ's `directorySyncController.requests.handle`. Resource types are resolved by examining the path segment: paths containing `Users` map to `"users"`, while all other resources map to `"groups"`.

```typescript
const handler = async (
  req: Request,
  { params: initialParams }: { params: Promise<Record<string, string[]>> },
) => {
  const params = (await initialParams) || {};
  const headersList = await headers();
  const authHeader = headersList.get("Authorization");
  const apiSecret = authHeader ? authHeader.split(" ")[1] : null;

  const query = getSearchParams(req.url);
  const [directoryId, path, resourceId] = params.directory;
  let body;
  try {
    body = await req.json();
  } catch (error) {
    body = {};
  }

  const { directorySyncController } = await jackson();

  // Handle the SCIM API requests
  const request: DirectorySyncRequest = {
    method: req.method,
    body,
    directoryId,
    resourceId,
    resourceType: path === "Users" ? "users" : "groups",
    apiSecret,
    query: {
      count: query.count ? parseInt(query.count as string) : undefined,
      startIndex: query.startIndex
        ? parseInt(query.startIndex as string)
        : undefined,
      filter: query.filter as string,
    },
  };

  const { status, data } = await directorySyncController.requests.handle(
    request,
    handleEvents,
  );

  return NextResponse.json(data, { status });
};

export { handler as DELETE, handler as GET, handler as POST, handler as PUT };
```
Sources: [apps/web/app/ee/api/scim/v2.0/...directory/route.ts:13-58](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/scim/v2.0/%5B...directory%5D/route.ts#L13-L58)

| Parameter / Field | Source / Origin | Type | Purpose |
| :--- | :--- | :--- | :--- |
| `directoryId` | `params.directory[0]` | `string` | Identifies the specific SCIM directory instance in Jackson. |
| `path` | `params.directory[1]` | `string` | Determines resource type classification (`Users` vs groups). |
| `resourceId` | `params.directory[2]` | `string \| undefined` | Target user or group identifier for specific resource operations. |
| `apiSecret` | `Authorization` header | `string \| null` | Bearer token credential verified against the directory secret. |
| `resourceType` | Mapped from `path` | `"users" \| "groups"` | Directs Jackson controller logic to user or group handlers. |

Sources: [apps/web/app/ee/api/scim/v2.0/...directory/route.ts:17-48](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/scim/v2.0/%5B...directory%5D/route.ts#L17-L48)

> [!WARNING]
> SCIM directory requests that omit or supply an invalid bearer token in the `Authorization` header will fail authentication within BoxyHQ Jackson's request handler, returning an unauthorized error response to the identity provider.
> Sources: [apps/web/app/ee/api/scim/v2.0/...directory/route.ts:18-20](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/ee/api/scim/v2.0/%5B...directory%5D/route.ts#L18-L20), [apps/web/app/ee/api/scim/v2.0/...directory/route.ts:50-55](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/ee/api/scim/v2.0/%5B...directory%5D/route.ts#L50-L55)

### Workspace Directory Lifecycle Management

Workspace SCIM directories are managed through administrative API endpoints at `apps/web/app/api/workspaces/[idOrSlug]/scim/route.ts`. These routes utilize `withWorkspace` middleware requiring workspace read/write permissions and an `enterprise` plan validation on creation. Supported providers for directory creation are restricted via Zod schema validation to `okta-scim-v2`, `azure-scim-v2`, and `google`. When a `currentDirectoryId` is provided during creation, the existing directory is retrieved, validated against the target workspace and product (`"Dub"`), and deleted concurrently with the creation of the new directory instance.

```typescript
const createDirectorySchema = z.object({
  provider: z.enum(["okta-scim-v2", "azure-scim-v2", "google"]).optional(),
  currentDirectoryId: z.string().min(1).optional(),
});

const deleteDirectorySchema = z.object({
  directoryId: z.string().min(1),
});

// GET /api/workspaces/[idOrSlug]/scim – get all SCIM directories
export const GET = withWorkspace(
  async ({ workspace }) => {
    const { directorySyncController } = await jackson();

    const { data, error } =
      await directorySyncController.directories.getByTenantAndProduct(
        workspace.id,
        "Dub",
      );
    if (error) {
      throw new DubApiError({
        code: "internal_server_error",
        message: error.message,
      });
    }

    return NextResponse.json({
      directories: data,
    });
  },
  {
    requiredPermissions: ["workspaces.read"],
  },
);

// POST /api/workspaces/[idOrSlug]/scim – create a new SCIM directory
export const POST = withWorkspace(
  async ({ req, workspace }) => {
    const { provider = "okta-scim-v2", currentDirectoryId } =
      createDirectorySchema.parse(await req.json());

    const { directorySyncController } = await jackson();

    if (currentDirectoryId) {
      const directory =
        await directorySyncController.directories.get(currentDirectoryId);

      if (!directory || directory.error || !directory.data) {
        throw new DubApiError({
          code: "not_found",
          message: directory?.error?.message ?? "Directory not found.",
        });
      }

      if (
        directory.data.tenant !== workspace.id ||
        directory.data.product !== "Dub"
      ) {
        throw new DubApiError({
          code: "not_found",
          message: "Directory not found.",
        });
      }
    }

    const [data, _] = await Promise.all([
      directorySyncController.directories.create({
        name: "Dub SCIM Directory",
        tenant: workspace.id,
        product: "Dub",
        type: provider,
      }),

      currentDirectoryId &&
        directorySyncController.directories.delete(currentDirectoryId),
    ]);

    return NextResponse.json(data);
  },
  {
```
Sources: [apps/web/app/api/workspaces/idOrSlug/scim/route.ts:7-90](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/workspaces/%5BidOrSlug%5D/scim/route.ts#L7-L90)

| HTTP Method | Route Path | Required Permission | Required Plan | Payload Schema | Description |
| :--- | :--- | :--- | :--- | :--- | :--- |
| `GET` | `/api/workspaces/[idOrSlug]/scim` | `workspaces.read` | None | None | Retrieves all SCIM directories associated with the workspace tenant and product `"Dub"`. |
| `POST` | `/api/workspaces/[idOrSlug]/scim` | `workspaces.write` | `enterprise` | `{ provider?: "okta-scim-v2" \| "azure-scim-v2" \| "google", currentDirectoryId?: string }` | Creates a new SCIM directory connection and optionally replaces an existing directory ID. |
| `DELETE` | `/api/workspaces/[idOrSlug]/scim` | `workspaces.write` | None | `{ directoryId: string }` (query params) | Validates and deletes an existing SCIM directory configuration by its ID. |

Sources: [apps/web/app/api/workspaces/idOrSlug/scim/route.ts:16-134](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/workspaces/%5BidOrSlug%5D/scim/route.ts#L16-L134)

```typescript
  scimPath:
    process.env.NODE_ENV === "production" ? "/scim/v2.0" : "/api/scim/v2.0", // custom SCIM endpoint
  clientSecretVerifier: process.env.NEXTAUTH_SECRET as string,
};
```
Sources: [apps/web/lib/jackson.ts:31-34](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/jackson.ts#L31-L34)

### Workspace User Synchronization and Event Handling

Directory sync events emitted by BoxyHQ Jackson are processed by `handleEvents` in the SCIM route module. The event payload supplies an action (`event`), a tenant ID (`tenant` mapping to `workspaceId`), and resource attributes (`data`). Workspace validation ensures the target project exists, operates on the `enterprise` plan, and includes an email address in the event data.

User lifecycle actions trigger specific database mutations:
- **Creation (`user.created`)**: When a user is provisioned and does not already belong to the workspace or possess an active project invite, `inviteUser` dispatches an invitation.
- **Activation (`user.updated`)**: If `data.active` evaluates to `true` or `"True"` (handling string variants from Azure AD), missing workspace members or invitees receive an invitation.
- **Deactivation or Deletion (`user.updated` with `active: false`/`"False"`, or `user.deleted`)**: Existing workspace user relations (`prisma.projectUsers`) and pending project invites (`prisma.projectInvite`) are removed from the database.

```typescript
// Handle the SCIM events
const handleEvents = async (event: DirectorySyncEvent) => {
  const { event: action, tenant: workspaceId, data } = event;

  const workspace = (await prisma.project.findUnique({
    where: {
      id: workspaceId,
    },
  })) as unknown as WorkspaceProps;

  if (!workspace || workspace.plan !== "enterprise" || !("email" in data)) {
    return;
  }

  const [userInWorkspace, userInvited] = await Promise.all([
    prisma.user.findFirst({
      where: {
        email: data.email,
        projects: {
          some: {
            projectId: workspaceId,
          },
        },
      },
    }),
    await prisma.projectInvite.findUnique({
      where: {
        email_projectId: {
          email: data.email,
          projectId: workspaceId,
        },
      },
    }),
  ]);

  // User has been activated for the first time
  if (action === "user.created" && !userInWorkspace && !userInvited) {
    await inviteUser({
      email: data.email,
      workspace,
    });
  }

  // User has been activated
  if (
    action === "user.updated" &&
    // @ts-ignore – data.active can be a string (from Azure AD)
    (data.active === true || data.active === "True")
  ) {
    if (!userInWorkspace && !userInvited) {
      await inviteUser({
        email: data.email,
        workspace,
      });
    }
  }

  // User has been deactivated or deleted
  if (
    (action === "user.updated" &&
      // @ts-ignore – data.active can be a string (from Azure AD)
      (data.active === false || data.active === "False")) ||
    action === "user.deleted"
  ) {
    if (userInWorkspace) {
      await prisma.projectUsers.delete({
        where: {
          userId_projectId: {
            userId: userInWorkspace.id,
            projectId: workspaceId,
          },
        },
      });
    }
    if (userInvited) {
      await prisma.projectInvite.delete({
        where: {
          email_projectId: {
            email: data.email,
            projectId: workspaceId,
```
Sources: [apps/web/app/ee/api/scim/v2.0/...directory/route.ts:60-145](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/scim/v2.0/%5B...directory%5D/route.ts#L60-L145)

> [!NOTE]
> Azure AD encodes user activation status as string values (`"True"` or `"False"`) rather than native booleans; the event handler explicitly checks both boolean types and string literals to correctly process user activations and deactivations.
> Sources: [apps/web/app/ee/api/scim/v2.0/...directory/route.ts:106-107](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/ee/api/scim/v2.0/%5B...directory%5D/route.ts#L106-L107), [apps/web/app/ee/api/scim/v2.0/...directory/route.ts:120-121](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/ee/api/scim/v2.0/%5B...directory%5D/route.ts#L120-L121)

## Directory Sync User Interface

### Client Security Settings Dashboard

The security settings interface for workspace directory synchronization is rendered by the `SCIM` component in `apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/security/scim.tsx`. This component integrates with workspace contexts and permission verification checks via `clientAccessCheck` for `workspaces.write` actions. It renders inside `WorkspaceSecurityClient` alongside SAML configuration and audit logs.

```tsx
export function SCIM() {
  const { plan, role } = useWorkspace();
  const { SCIMModal, setShowSCIMModal } = useSCIMModal();
  const { RemoveSCIMModal, setShowRemoveSCIMModal } = useRemoveSCIMModal();

  const { provider, configured, loading } = useSCIM();

  const permissionsError = clientAccessCheck({
    action: "workspaces.write",
    role,
    customPermissionDescription: "configure SCIM Directory Sync",
  }).error;

```
Sources: [apps/web/app/app.dub.co/dashboard/slug/ee/settings/security/scim.tsx:14-26](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/settings/security/scim.tsx#L14-L26), [apps/web/app/app.dub.co/dashboard/slug/ee/settings/security/page-client.tsx:7-14](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/settings/security/page-client.tsx#L7-L14)

```typescript
export default function useSCIM() {
  const { id } = useWorkspace();

  const { data, isLoading, mutate } = useSWR<{ directories: Directory[] }>(
    id && `/api/workspaces/${id}/scim`,
    fetcher,
    {
      keepPreviousData: true,
    },
  );

  const configured = useMemo(() => {
    return data?.directories && data.directories.length > 0;
  }, [data]);

  return {
    scim: data as { directories: Directory[] },
    provider: configured ? data!.directories[0].type : null,
    configured,
    loading: isLoading,
    mutate,
  };
```
Sources: [apps/web/lib/swr/use-scim.ts:7-28](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/swr/use-scim.ts#L7-L28)

The SWR data hook `useSCIM` fetches directory configuration states from `/api/workspaces/[id]/scim` with `keepPreviousData: true` enabled. It returns `scim`, `provider`, `configured` (evaluating whether `data.directories` contains one or more items), `loading`, and `mutate`.

> [!NOTE]
> When the workspace subscription plan is not set to `enterprise`, clicking the configure action button reveals a disabled tooltip pointing users toward enterprise upgrade paths and sales inquiries.
> Sources: [apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/security/scim.tsx:147-163](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/settings/security/scim.tsx#L147-L163)

### SCIM Configuration and Removal Modals

Workspace administrators configure or inspect directory sync settings via `SCIMModal` defined in `apps/web/ui/modals/scim-modal.tsx`. The modal submits a `POST` request to `/api/workspaces/[id]/scim` with the chosen `provider` identifier and, if already configured, the `currentDirectoryId` extracted from `scim.directories[0].id`.

```tsx
<form
  onSubmit={async (e) => {
    e.preventDefault();
    setSubmitting(true);
    fetch(`/api/workspaces/${id}/scim`, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        provider: e.currentTarget.provider.value,
        ...(configured && {
          currentDirectoryId: scim.directories[0].id,
        }),
      }),
    }).then(async (res) => {
      if (res.ok) {
        await mutate();
        toast.success("Successfully configured SCIM");
      } else {
        const { error } = await res.json();
        toast.error(error.message);
      }
      setSubmitting(false);
    });
  }}
```
Sources: [apps/web/ui/modals/scim-modal.tsx:90-115](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/ui/modals/scim-modal.tsx#L90-L115)

```tsx
const removeSCIM = async () => {
  setRemoving(true);
  if (!scim?.directories[0]) {
    toast.error("No SCIM directories found");
    setRemoving(false);
    return;
  }
  const { id } = scim.directories[0];
  const params = new URLSearchParams({
    directoryId: id,
  });

  const res = await fetch(`/api/workspaces/${workspaceId}/scim?${params}`, {
    method: "DELETE",
  });
  setRemoving(false);
  if (res.ok) {
    await mutate();
    setShowRemoveSCIMModal(false);
    toast.success("SCIM directory removed successfully");
  } else {
    const { error } = await res.json();
    toast.error(error.message);
  }
};
```
Sources: [apps/web/ui/modals/remove-scim-modal.tsx:37-61](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/ui/modals/remove-scim-modal.tsx#L37-L61)

Removal of an active SCIM directory is handled by `RemoveSCIMModal`, which requires explicit text verification matching the string `"confirm remove scim"` before enabling the deletion button. Upon verification, it issues a `DELETE` request to `/api/workspaces/[workspaceId]/scim?directoryId=id`.

## Related

- [OAuth Apps](https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/integrations-subsystem/oauth-apps)
- [Admin Dashboard](https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/administration-and-api/admin-dashboard)


## Sitemap

See the full [sitemap](https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/llms.txt) for all pages in this wiki.
