Skip to main content
doc0blade47/dubdocumentation home
Sign in

Authentication

Last updated Oct 12, 2026100% grounded
Relevant source files

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

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, apps/web/lib/jackson.ts:42-58

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

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

Provider IDProvider NameTypeKey Configuration
emailEmailemailRate-limited login link dispatch (RATELIMIT_POLICIES.loginLinkSend)
googleGoogleoauthallowDangerousEmailAccountLinking: true
githubGitHuboauthissuer: "https://github.com/login/oauth", allowDangerousEmailAccountLinking: true
samlBoxyHQoauthchecks: ["pkce", "state"], grant type authorization_code
saml-idpIdP LogincredentialsCustom authorize handling Jackson OAuth token and userInfo exchange
credentialsDub.cocredentialsEmail and password validation with SSO enforcement and brute-force protection

Sources: apps/web/lib/auth/options.ts:97-263

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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, apps/web/lib/auth/workspace.ts:411-418

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 ContextIdentifier Key PatternRequest LimitTime Interval
API Session Requestsworkspace:ratelimit:{userId}600 requests1 minute
Analytics API Sessionsworkspace:ratelimit:{userId}12 requests1 second
Workspace API Keysworkspace:ratelimit:{hashedKey}Plan-dependent (getRatelimitForPlan)1 minute
Analytics API Keysworkspace:ratelimit:{hashedKey}Plan-dependent (getRatelimitForPlan)1 second
Token Last-Used Trackerlast-used-{hashedKey}1 request1 minute

Sources: apps/web/lib/auth/workspace.ts:27-36, apps/web/lib/auth/workspace.ts:231-245, apps/web/lib/auth/workspace.ts:269-272, apps/web/lib/auth/workspace.ts:314-322

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. Sources: apps/web/lib/auth/workspace.ts:355-382

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.

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

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

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().
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

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

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

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

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

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 NameSAML IdentifierSCIM IdentifierSaml Modal CopyScim URL LabelScim Token Label
Oktaoktaokta-scim-v2Metadata URLSCIM 2.0 Base URLOAuth Bearer Token
Entra ID (formerly Azure AD)azureazure-scim-v2App Federation Metadata URLTenant URLSecret Token
GooglegooglegoogleXML Metadata FileSCIM 2.0 Base URLOAuth Bearer Token

Sources: packages/utils/src/constants/saml.ts:1-38

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

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.

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

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, apps/web/lib/api/workspaces/is-saml-enforced-for-email-domain.ts:11-18

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.

MethodRoute PathRequired PermissionRequired PlanPayload / Query SchemaDescription
GET/api/workspaces/[idOrSlug]/samlworkspaces.readNoneNoneRetrieves configured SAML connections for the workspace, audience issuer, and ACS callback URLs.
POST/api/workspaces/[idOrSlug]/samlworkspaces.writeenterprise{ metadataUrl?: string, encodedRawMetadata?: string }Creates a new SAML connection via BoxyHQ Jackson and updates the project's ssoEmailDomain.
DELETE/api/workspaces/[idOrSlug]/samlworkspaces.writeNone{ 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

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

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

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

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 }).

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

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

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".

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

Parameter / FieldSource / OriginTypePurpose
directoryIdparams.directory[0]stringIdentifies the specific SCIM directory instance in Jackson.
pathparams.directory[1]stringDetermines resource type classification (Users vs groups).
resourceIdparams.directory[2]string | undefinedTarget user or group identifier for specific resource operations.
apiSecretAuthorization headerstring | nullBearer token credential verified against the directory secret.
resourceTypeMapped 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

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, apps/web/app/ee/api/scim/v2.0/...directory/route.ts:50-55

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.

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

HTTP MethodRoute PathRequired PermissionRequired PlanPayload SchemaDescription
GET/api/workspaces/[idOrSlug]/scimworkspaces.readNoneNoneRetrieves all SCIM directories associated with the workspace tenant and product "Dub".
POST/api/workspaces/[idOrSlug]/scimworkspaces.writeenterprise{ 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]/scimworkspaces.writeNone{ 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

  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

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.
// 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

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, apps/web/app/ee/api/scim/v2.0/...directory/route.ts:120-121

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.

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, apps/web/app/app.dub.co/dashboard/slug/ee/settings/security/page-client.tsx:7-14

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

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

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.

<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

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

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.

Was this page helpful?