---
title: "OAuth Apps"
description: "Dub features a complete OAuth 2.0 authorization server implementation that enables third-party developers and internal clients, such as the Dub CLI, to securely integrate with Dub workspaces. Devel..."
last_updated: "2026-10-12T07:00:10.142659+00:00"
canonical_url: "https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/integrations-subsystem/oauth-apps"
---

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

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

- [apps/web/app/api/oauth/apps/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/oauth/apps/route.ts)
- [apps/web/app/api/oauth/authorize/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/oauth/authorize/route.ts)
- [packages/cli/src/utils/oauth.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/packages/cli/src/utils/oauth.ts)
- [apps/web/app/app.dub.co/auth/oauth/authorize/page.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/app.dub.co/(auth)/oauth/authorize/page.tsx)
- [apps/web/app/api/tokens/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/tokens/route.ts)
- [apps/web/lib/api/oauth/constants.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/api/oauth/constants.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/api/oauth/apps/appId/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/oauth/apps/%5BappId%5D/route.ts)
- [packages/stripe-app/src/utils/oauth.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/packages/stripe-app/src/utils/oauth.ts)
- [apps/web/app/api/oauth/token/exchange-code-for-token.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/oauth/token/exchange-code-for-token.ts)
- [packages/cli/src/api/callback.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/packages/cli/src/api/callback.ts)
- [apps/web/app/ee/api/hubspot/callback/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/hubspot/callback/route.ts)
- [apps/web/app/api/slack/callback/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/slack/callback/route.ts)
- [apps/web/app/api/oauth/token/refresh-access-token.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/oauth/token/refresh-access-token.ts)
- [packages/cli/src/commands/login.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/packages/cli/src/commands/login.ts)
- [apps/web/lib/integrations/oauth-provider.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/integrations/oauth-provider.ts)
- [apps/web/app/api/callback/bitly/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/callback/bitly/route.ts)
- [apps/web/app/app.dub.co/auth/oauth/authorize/authorize-form.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/app.dub.co/(auth)/oauth/authorize/authorize-form.tsx)
- [apps/web/lib/integrations/bitly/oauth.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/integrations/bitly/oauth.ts)
- [apps/web/app/api/oauth/token/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/oauth/token/route.ts)
- [apps/web/app/app.dub.co/auth/oauth/authorize/scopes-requested.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/app.dub.co/(auth)/oauth/authorize/scopes-requested.tsx)
- [apps/web/app/app.dub.co/dashboard/slug/ee/settings/oauth-apps/page.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/settings/oauth-apps/page.tsx)
- [apps/web/app/app.dub.co/dashboard/slug/ee/settings/oauth-apps/appId/page-client.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/settings/oauth-apps/%5BappId%5D/page-client.tsx)
- [apps/web/app/app.dub.co/dashboard/slug/ee/settings/oauth-apps/new/page-client.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/settings/oauth-apps/new/page-client.tsx)
- [apps/web/app/app.dub.co/dashboard/slug/ee/settings/oauth-apps/layout.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/settings/oauth-apps/layout.tsx)
- [apps/web/app/app.dub.co/dashboard/slug/ee/settings/oauth-apps/page-client.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/settings/oauth-apps/page-client.tsx)
- [apps/web/app/app.dub.co/dashboard/slug/ee/settings/oauth-apps/new/page.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/settings/oauth-apps/new/page.tsx)
- [apps/web/lib/actions/generate-client-secret.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/actions/generate-client-secret.ts)
- [apps/web/app/app.dub.co/dashboard/slug/ee/settings/oauth-apps/appId/page.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/settings/oauth-apps/%5BappId%5D/page.tsx)
- [apps/web/prisma/schema/oauth.prisma](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/prisma/schema/oauth.prisma)
</details>

## Overview

Dub features a complete OAuth 2.0 authorization server implementation that enables third-party developers and internal clients, such as the Dub CLI, to securely integrate with Dub workspaces. Developers can register and manage OAuth applications directly within workspace developer settings, configuring redirect URIs, scopes, and PKCE requirements. The platform provides an interactive consent and authorization workflow where users select a target workspace, review requested permission scopes against their role privileges, and approve or decline access. Authorized requests issue short-lived authorization codes that are exchanged via secure token endpoints for access and refresh tokens, supporting both confidential client secrets and public PKCE verification.

Sources: [apps/web/prisma/schema/oauth.prisma:1-49](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/prisma/schema/oauth.prisma#L1-L49), [apps/web/app/api/oauth/apps/route.ts:15-160](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/oauth/apps/route.ts#L15-L160), [apps/web/app/api/oauth/authorize/route.ts:17-132](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/oauth/authorize/route.ts#L17-L132), [apps/web/app/app.dub.co/auth/oauth/authorize/page.tsx:23-114](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/app.dub.co/(auth)/oauth/authorize/page.tsx#L23-L114), [apps/web/app/api/oauth/token/exchange-code-for-token.ts:15-230](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/oauth/token/exchange-code-for-token.ts#L15-L230), [packages/cli/src/commands/login.ts:9-37](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/packages/cli/src/commands/login.ts#L9-L37)

## OAuth Application Storage and Schema

### Database Persistence Architecture

The OAuth persistence layer is managed via Prisma queries targeting `prisma.oAuthApp` (representing registered application credentials linked to an integration), `prisma.oAuthCode` (tracking short-lived authorization codes issued during consent prompts), and `prisma.oAuthRefreshToken` (managing token longevity by linking installations to restricted access tokens). The `oAuthApp` database table stores credentials including `clientId`, `hashedClientSecret`, `partialClientSecret`, `redirectUris` stored as JSON, and a `pkce` boolean flag for PKCE enforcement.

Sources: [apps/web/prisma/schema/oauth.prisma:1-12](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/prisma/schema/oauth.prisma#L1-L12), [apps/web/app/api/oauth/apps/route.ts:17-30](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/oauth/apps/route.ts#L17-L30)

Authorization codes issued during user consent prompts are inserted into `prisma.oAuthCode`, recording `clientId`, authorizing `userId`, workspace `projectId`, the unique `code` string, granted `scopes`, `redirectUri`, `expiresAt` timestamps, and optional PKCE parameters `codeChallenge` and `codeChallengeMethod`.

Sources: [apps/web/prisma/schema/oauth.prisma:14-34](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/prisma/schema/oauth.prisma#L14-L34), [apps/web/app/api/oauth/authorize/route.ts:102-114](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/oauth/authorize/route.ts#L102-L114)

Long-lived sessions use `prisma.oAuthRefreshToken` to persist credentials, linking an installation record (`installationId`) to an issued access token (`accessTokenId`) via `hashedRefreshToken` and tracking an `expiresAt` date. Access tokens themselves are stored as `prisma.restrictedToken` records, establishing the foundation for API authentication across Dub's endpoints (documented in [REST API](https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/administration-and-api/rest-api), which handles general token querying and API key authentication).

Sources: [apps/web/prisma/schema/oauth.prisma:36-49](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/prisma/schema/oauth.prisma#L36-L49), [apps/web/app/api/oauth/token/exchange-code-for-token.ts:180-199](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/oauth/token/exchange-code-for-token.ts#L180-L199), [apps/web/app/api/tokens/route.ts:26-65](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/tokens/route.ts#L26-L65)

### Configuration Constants and Lifetimes

OAuth parameters and cryptographic lengths are governed by strict configuration constants defined in `OAUTH_CONFIG`. Token lifetimes are measured in seconds, defining short-lived authorization codes and access tokens alongside long-lived refresh tokens. Token and identifier lengths specify exact character counts, while prefix constants standardize naming conventions for generated identifiers and secrets across the system.

| Constant Name | Value / Length | Meaning / Purpose |
| :--- | :--- | :--- |
| `ACCESS_TOKEN_LIFETIME` | `7200` (2 hours) | Validity duration for issued access tokens |
| `REFRESH_TOKEN_LIFETIME` | `10368000` (120 days) | Validity duration for issued refresh tokens |
| `CODE_LIFETIME` | `120` (2 minutes) | Expiry window for authorization codes |
| `CLIENT_ID_LENGTH` | `24` characters | Generated length for client identifiers |
| `CLIENT_SECRET_LENGTH` | `30` characters | Generated length for raw client secrets |
| `ACCESS_TOKEN_LENGTH` | `40` characters | Generated length for access tokens |
| `REFRESH_TOKEN_LENGTH` | `40` characters | Generated length for refresh tokens |
| `CODE_LENGTH` | `40` characters | Generated length for authorization codes |
| `CLIENT_ID_PREFIX` | `dub_app_` | Prefix string prepended to client IDs |
| `CLIENT_SECRET_PREFIX` | `dub_app_secret_` | Prefix string prepended to client secrets |
| `ACCESS_TOKEN_PREFIX` | `dub_access_token_` | Prefix string prepended to access tokens |

Sources: [apps/web/lib/api/oauth/constants.ts:1-16](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/api/oauth/constants.ts#L1-L16)

> [!NOTE]
> The `user.read` permission scope acts as the default baseline scope, meaning client applications do not need to explicitly request it during the authorization phase. All other scopes like `links.read`, `links.write`, and `analytics.read` must be explicitly declared.

Sources: [apps/web/lib/api/oauth/constants.ts:21-34](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/api/oauth/constants.ts#L21-L34)

### Supported Scopes and Descriptions

OAuth applications can request targeted permission scopes on behalf of users. The available scopes govern granular access to workspace resources, webhooks, and account profiles.

| Scope Identifier | Description |
| :--- | :--- |
| `links.read` | Read access to links |
| `links.write` | Read and Write access to links |
| `tags.read` | Read access to tags |
| `tags.write` | Read and Write access to tags |
| `analytics.read` | Read access to analytics and events |
| `domains.read` | Read access to domains |
| `domains.write` | Read and Write access to domains |
| `user.read` | Read your name, email and profile image |
| `webhooks.read` | Read access to webhooks |
| `webhooks.write` | Read and Write access to webhooks |
| `folders.read` | Read access to folders |
| `folders.write` | Read and Write access to folders |

Sources: [apps/web/lib/api/oauth/constants.ts:21-50](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/api/oauth/constants.ts#L21-L50)

## OAuth App Management in Dashboard

### Workspace Developer Settings and App Registration

Workspace settings provide a developer section under `/[slug]/settings/oauth-apps` for managing integrations. The user interface fetches registered applications via SWR from `/api/oauth/apps?workspaceId={workspaceId}` or presents an empty state via `EmptyState` with a cube icon when no apps exist.

Sources: [apps/web/app/app.dub.co/dashboard/slug/ee/settings/oauth-apps/page-client.tsx:12-43](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/settings/oauth-apps/page-client.tsx#L12-L43), [apps/web/app/app.dub.co/dashboard/slug/ee/settings/oauth-apps/layout.tsx:8-19](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/settings/oauth-apps/layout.tsx#L8-L19)

Applications are registered via `POST /api/oauth/apps`, which requires the `oauth_apps.write` permission. The endpoint parses request bodies using `createOAuthAppSchema`, checks for slug conflicts against existing `Integration` records, and generates a client ID using `createToken` with `OAUTH_CONFIG.CLIENT_ID_LENGTH` and `OAUTH_CONFIG.CLIENT_ID_PREFIX`. If PKCE is disabled (`!pkce`), a raw client secret is generated using `OAUTH_CONFIG.CLIENT_SECRET_LENGTH` and `OAUTH_CONFIG.CLIENT_SECRET_PREFIX`, hashed via `hashToken`, and partially exposed as `dub_app_secret_****${clientSecret.slice(-8)}`. Logo uploads are stored in object storage under `integrations/{integration.id}_{nanoid(7)}`.

Sources: [apps/web/app/api/oauth/apps/route.ts:45-160](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/oauth/apps/route.ts#L45-L160)

> [!CAUTION]
> The raw client secret is returned in the HTTP response body exactly once upon creation. If lost, it cannot be retrieved and must be regenerated through the secret rotation mechanism.

Sources: [apps/web/app/api/oauth/apps/route.ts:136-142](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/oauth/apps/route.ts#L136-L142)

### App Inspection and Secret Rotation

Individual apps are managed at `/[slug]/settings/oauth-apps/[appId]`. The management client validates write access using `clientAccessCheck` and loads application metadata via SWR from `/api/oauth/apps/[appId]?workspaceId={workspaceId}`.

Sources: [apps/web/app/app.dub.co/dashboard/slug/ee/settings/oauth-apps/appId/page-client.tsx:23-57](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/settings/oauth-apps/%5BappId%5D/page-client.tsx#L23-L57)

Client secrets can be rotated using the server action exported from `generate-client-secret.ts`, wrapped by `authActionClient`. The action verifies that the user holds the `oauth_apps.write` permission, checks that the integration exists within the workspace, generates a new token of length `OAUTH_CONFIG.CLIENT_SECRET_LENGTH` with prefix `OAUTH_CONFIG.CLIENT_SECRET_PREFIX`, updates the `prisma.oAuthApp` record with a new `hashedClientSecret` and `partialClientSecret`, and returns the raw secret.

Sources: [apps/web/lib/actions/generate-client-secret.ts:17-50](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/actions/generate-client-secret.ts#L17-L50)

Updates and deletions are handled by `PATCH /api/oauth/apps/[appId]` and `DELETE /api/oauth/apps/[appId]`. Updates upload new logos or delete old screenshots asynchronously using `@vercel/functions` `waitUntil`. Deletions remove associated storage assets and delete the corresponding integration record in the database.

Sources: [apps/web/app/api/oauth/apps/appId/route.ts:52-214](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/oauth/apps/%5BappId%5D/route.ts#L52-L214)

## Authorization Flow and Consent UI

### Interactive Authorization and Consent Page

The OAuth authorization flow begins when a user navigates to the consent page at `/auth/oauth/authorize`. The page validates incoming query parameters using `validateAuthorizeRequest` against the `authorizeRequestSchema`. If the request parameters are invalid or the target integration is missing, an `EmptyState` component with the `CubeSettings` icon displays an error. When valid, the page renders a consent card displaying the integration's logo, name, developer information, and a warning banner if the app is unverified.

Sources: [apps/web/app/app.dub.co/auth/oauth/authorize/page.tsx:23-114](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/app.dub.co/(auth)/oauth/authorize/page.tsx#L23-L114)

> [!WARNING]
> Unverified applications display a prominent warning banner and can only be installed by their developer or within the developer's workspace.

Sources: [apps/web/app/app.dub.co/auth/oauth/authorize/page.tsx:85-101](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/app.dub.co/(auth)/oauth/authorize/page.tsx#L85-L101)

### Workspace Selector and Scope Validation

The consent form incorporates an interactive `WorkspaceSelector` managed through `AuthorizeWorkspaceProvider`. Users must choose a target workspace before granting API access. Requested scopes are processed by `ScopesRequested`, which automatically appends the default `user.read` scope if absent and filters permissions against the user's role in the selected workspace via `getDisplayedScopesForRole`. Missing permissions display a warning tooltip.

Sources: [apps/web/app/app.dub.co/auth/oauth/authorize/authorize-form.tsx:33-121](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/app.dub.co/(auth)/oauth/authorize/authorize-form.tsx#L33-L121), [apps/web/app/app.dub.co/auth/oauth/authorize/scopes-requested.tsx:11-57](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/app.dub.co/(auth)/oauth/authorize/scopes-requested.tsx#L11-L57)

> [!NOTE]
> If a user's role lacks a requested scope, a warning icon appears beside the permission, and the scope is excluded from the final grant without failing the entire authorization request.

Sources: [apps/web/app/app.dub.co/auth/oauth/authorize/scopes-requested.tsx:43-52](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/app.dub.co/(auth)/oauth/authorize/scopes-requested.tsx#L43-L52), [apps/web/app/app.dub.co/auth/oauth/authorize/scopes-requested.tsx:93-100](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/app.dub.co/(auth)/oauth/authorize/scopes-requested.tsx#L93-L100)

### Authorization Request Approval and Code Issuance

Submitting the consent form triggers a `POST` request to `/api/oauth/authorize` with the selected workspace identifier. The endpoint enforces `integrations.write` permissions, calculates granted scopes using `getGrantedScopesForRole`, and validates client configuration, redirect URIs, and PKCE parameters. For Stripe and Shopify integrations, it checks that the workspace plan is Business tier or higher.

Sources: [apps/web/app/api/oauth/authorize/route.ts:17-87](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/oauth/authorize/route.ts#L17-L87)

| Validation Check | Error Code | Trigger Condition |
| :--- | :--- | :--- |
| Application existence | `not_found` | Integration record not found for `clientId` |
| Plan requirement | `bad_request` | Stripe or Shopify integration requested on a `free` or `pro` workspace plan |
| Redirect URI | `bad_request` | Provided `redirect_uri` is absent from the application's allowed list |
| PKCE parameters | `bad_request` | `app.pkce` is enabled but `code_challenge` or `code_challenge_method` is missing |
| App installation | `forbidden` | User lacks installation rights for an unverified application |

Sources: [apps/web/app/api/oauth/authorize/route.ts:52-100](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/oauth/authorize/route.ts#L52-L100)

Upon successful validation, an authorization code is generated using `createToken` with a length of `OAUTH_CONFIG.CODE_LENGTH` and persisted in the database via `prisma.oAuthCode.create` with an expiration window defined by `OAUTH_CONFIG.CODE_LIFETIME` (2 minutes). The endpoint constructs a callback URL appending the authorization code and optional `state`, returning it to the client for redirection.

Sources: [apps/web/app/api/oauth/authorize/route.ts:102-129](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/oauth/authorize/route.ts#L102-L129), [apps/web/lib/api/oauth/constants.ts:5](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/api/oauth/constants.ts#L5), [apps/web/lib/api/oauth/constants.ts:11](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/api/oauth/constants.ts#L11)

## Token Exchange and PKCE Verification

### Handling Token Grant Requests

The endpoint located at `/api/oauth/token` processes incoming `POST` requests containing form-urlencoded payloads. The maximum execution duration for this route is set to 30 seconds, and all responses incorporate standard Cross-Origin Resource Sharing (CORS) headers via `COMMON_CORS_HEADERS`. Incoming data is parsed using `tokenGrantSchema`, routing payloads with a `grant_type` of `authorization_code` to `exchangeAuthCodeForToken` and `refresh_token` requests to `refreshAccessToken`.

```typescript
export const maxDuration = 30;

// POST /api/oauth/token - Exchange authorization code for access token and refresh access token
export async function POST(req: NextRequest) {
  try {
    const formData = Object.fromEntries(await req.formData());
    const validatedData = tokenGrantSchema.parse(formData);

    if (validatedData.grant_type === "authorization_code") {
      const data = await exchangeAuthCodeForToken(req, validatedData);
      return NextResponse.json(data, {
        headers: COMMON_CORS_HEADERS,
      });
    } else if (validatedData.grant_type === "refresh_token") {
      const data = await refreshAccessToken(req, validatedData);
      return NextResponse.json(data, {
        headers: COMMON_CORS_HEADERS,
      });
    }
  } catch (error) {
    return handleAndReturnErrorResponse(error, COMMON_CORS_HEADERS);
  }
}
```
Sources: [apps/web/app/api/oauth/token/route.ts:8-30](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/oauth/token/route.ts#L8-L30)

| Grant Type | Handler Function | Purpose |
| :--- | :--- | :--- |
| `authorization_code` | `exchangeAuthCodeForToken` | Exchanges a valid authorization code for an access token and refresh token |
| `refresh_token` | `refreshAccessToken` | Rotates an existing refresh token to issue a new access token |

Sources: [apps/web/app/api/oauth/token/route.ts:16-26](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/oauth/token/route.ts#L16-L26)

> [!NOTE]
> If neither `client_id` nor `client_secret` is present in the request body, the handler inspects the `Authorization` header for `Basic` authentication credentials, decoding the base64-encoded string to extract the client identifier and secret.

Sources: [apps/web/app/api/oauth/token/exchange-code-for-token.ts:27-41](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/oauth/token/exchange-code-for-token.ts#L27-L41)

### PKCE and Client Secret Validation

When PKCE is enabled on an OAuth application (`app.pkce`), the `code_verifier` parameter becomes mandatory. If PKCE is disabled, the `client_secret` is required, hashed via `hashToken`, and compared against `app.hashedClientSecret` using `timingSafeCompare` to prevent timing attacks.

```typescript
// When PKCE is enabled, the code_verifier is required
if (app.pkce) {
  if (!codeVerifier) {
    throw new DubApiError({
      code: "bad_request",
      message: "Missing code_verifier parameter",
    });
  }
}

// When PKCE is not enabled, the client_secret is required
else if (!app.pkce) {
  if (!clientSecret) {
    throw new DubApiError({
      code: "unauthorized",
      message: "Missing client_secret",
    });
  }

  const hashedClientSecret = await hashToken(clientSecret);
  if (!timingSafeCompare(hashedClientSecret, app.hashedClientSecret)) {
    throw new DubApiError({
      code: "unauthorized",
      message: "Invalid client_secret",
    });
  }
}
```
Sources: [apps/web/app/api/oauth/token/exchange-code-for-token.ts:85-111](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/oauth/token/exchange-code-for-token.ts#L85-L111)

For PKCE-enabled exchanges, the code challenge verification inspects `accessCode.codeChallengeMethod`. If set to `S256`, the `code_verifier` is hashed using `generateCodeChallengeHash`; otherwise, it is evaluated directly against `accessCode.codeChallenge`. Mismatches result in an `unauthorized` error with an `invalid_grant` message.

```typescript
if (app.pkce) {
  const codeChallenge =
    accessCode.codeChallengeMethod === "S256"
      ? await generateCodeChallengeHash(codeVerifier!)
      : codeVerifier;

  if (accessCode.codeChallenge != codeChallenge) {
    throw new DubApiError({
      code: "unauthorized",
      message: "invalid_grant",
    });
  }
}
```
Sources: [apps/web/app/api/oauth/token/exchange-code-for-token.ts:120-132](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/oauth/token/exchange-code-for-token.ts#L120-L132)

> [!WARNING]
> Expired authorization codes trigger an immediate deletion via `prisma.oAuthCode.delete` accompanied by a `bad_request` error response indicating that the code has expired.

Sources: [apps/web/app/api/oauth/token/exchange-code-for-token.ts:134-146](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/oauth/token/exchange-code-for-token.ts#L134-L146)

### Token Issuance and Persistence

Upon successful validation, the system installs the integration for the user and workspace, then provisions a restricted access token and a refresh token. Lifetimes and token string characteristics are governed by `OAUTH_CONFIG`.

```typescript
export const OAUTH_CONFIG = {
  ACCESS_TOKEN_LIFETIME: 2 * 60 * 60, // 2 hours
  REFRESH_TOKEN_LIFETIME: 120 * 24 * 60 * 60, // 120 days
  CODE_LIFETIME: 2 * 60, // 2 minutes

  CLIENT_ID_LENGTH: 24,
  CLIENT_SECRET_LENGTH: 30,
  ACCESS_TOKEN_LENGTH: 40,
  REFRESH_TOKEN_LENGTH: 40,
  CODE_LENGTH: 40,

  CLIENT_ID_PREFIX: "dub_app_",
  CLIENT_SECRET_PREFIX: "dub_app_secret_",
  ACCESS_TOKEN_PREFIX: "dub_access_token_",
};
```
Sources: [apps/web/lib/api/oauth/constants.ts:2-16](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/api/oauth/constants.ts#L2-L16)

| Configuration Parameter | Value | Description |
| :--- | :--- | :--- |
| `ACCESS_TOKEN_LIFETIME` | `7200` (2 hours) | Lifetime of issued access tokens in seconds |
| `REFRESH_TOKEN_LIFETIME` | `10368000` (120 days) | Lifetime of refresh tokens in seconds |
| `CODE_LIFETIME` | `120` (2 minutes) | Expiration window for authorization codes |
| `ACCESS_TOKEN_LENGTH` | `40` | Character length for access tokens |
| `REFRESH_TOKEN_LENGTH` | `40` | Character length for refresh tokens |
| `ACCESS_TOKEN_PREFIX` | `dub_access_token_` | String prefix prepended to access tokens |

Sources: [apps/web/lib/api/oauth/constants.ts:2-16](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/api/oauth/constants.ts#L2-L16)

Once tokens are created, Vercel's `waitUntil` function asynchronously deletes the consumed authorization code and purges any prior restricted tokens associated with the same installation identifier, ensuring only one active token set remains valid per client-user-workspace combination.

```typescript
waitUntil(
  Promise.all([
    // Delete the code after it's been used
    prisma.oAuthCode.delete({
      where: {
        code,
      },
    }),

    // Remove all existing tokens for this client
    prisma.restrictedToken.deleteMany({
      where: {
        installationId: installation.id,
        id: {
          not: restrictedToken.id,
        },
      },
    }),
  ]),
);
```
Sources: [apps/web/app/api/oauth/token/exchange-code-for-token.ts:201-220](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/oauth/token/exchange-code-for-token.ts#L201-L220)

## Token Refresh and User Info

### Refreshing Expired Access Tokens

Clients request new access tokens by submitting a `POST` request to `/api/oauth/token` with `grant_type` set to `refresh_token`. The `refreshAccessToken` handler accepts the payload, resolving credentials from the request body or via HTTP Basic Authentication credentials parsed from the `Authorization` header.

```typescript
export const refreshAccessToken = async (
  req: NextRequest,
  params: z.infer<typeof refreshTokenSchema>,
) => {
  let {
    refresh_token,
    client_id: clientId,
    client_secret: clientSecret,
  } = params;

  // If no client_id or client_secret is provided in the request body
  // then it should be provided in the Authorization header as Basic Auth for non-PKCE
  if (!clientId && !clientSecret) {
    const authorizationHeader = req.headers.get("Authorization") || "";
    const [type, token] = authorizationHeader.split(" ");

    if (type === "Basic") {
      const splits = Buffer.from(token, "base64").toString("utf-8").split(":");

      if (splits.length > 1) {
        clientId = splits[0];
        clientSecret = splits[1];
      }
    }
  }
```
Sources: [apps/web/app/api/oauth/token/refresh-access-token.ts:13-37](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/oauth/token/refresh-access-token.ts#L13-L37)

The application validates the `client_id` against `prisma.oAuthApp` and evaluates PKCE support. If PKCE is disabled, the supplied `client_secret` is hashed and checked against `hashedClientSecret` using `timingSafeCompare`.

```typescript
const oAuthApp = await prisma.oAuthApp.findUnique({
  where: {
    clientId,
  },
  select: {
    pkce: true,
    integrationId: true,
    hashedClientSecret: true,
  },
});

if (!oAuthApp) {
  throw new DubApiError({
    code: "unauthorized",
    message: "OAuth app not found for the provided client_id",
  });
}

if (!oAuthApp.pkce) {
  if (!clientSecret) {
    throw new DubApiError({
      code: "unauthorized",
      message: "Missing client_secret",
    });
  }

  const hashedClientSecret = await hashToken(clientSecret);
  if (!timingSafeCompare(hashedClientSecret, oAuthApp.hashedClientSecret)) {
    throw new DubApiError({
      code: "unauthorized",
      message: "Invalid client_secret",
    });
  }
}
```
Sources: [apps/web/app/api/oauth/token/refresh-access-token.ts:46-79](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/oauth/token/refresh-access-token.ts#L46-L79)

> [!CAUTION]
> If a refresh token record is missing in `prisma.oAuthRefreshToken`, or if its `expiresAt` timestamp precedes the current system time, an unauthorized error is thrown, invalidating the refresh attempt.

Sources: [apps/web/app/api/oauth/token/refresh-access-token.ts:99-112](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/oauth/token/refresh-access-token.ts#L99-L112)

Upon validating the refresh token and its installation association, the old restricted access token is deleted within a Prisma transaction, and a replacement access token and refresh token are generated.

```typescript
await prisma.$transaction([
  // Delete the old access token
  prisma.restrictedToken.delete({
    where: {
      id: accessToken.id,
    },
  }),

  // Create the access token and refresh token
  prisma.restrictedToken.create({
    data: {
      name: generateRandomName(),
      hashedKey: await hashToken(newAccessToken),
      partialKey: `${newAccessToken.slice(0, 3)}...${newAccessToken.slice(-4)}`,
      scopes: accessToken.scopes,
      expires: accessTokenExpires,
      userId: authorizedApp.userId,
      projectId: authorizedApp.projectId,
      installationId: authorizedApp.id,
      refreshTokens: {
        create: {
          installationId: authorizedApp.id,
          hashedRefreshToken: await hashToken(newRefreshToken),
          expiresAt: new Date(
            Date.now() + OAUTH_CONFIG.REFRESH_TOKEN_LIFETIME * 1000,
          ),
        },
      },
    },
  }),
]);
```
Sources: [apps/web/app/api/oauth/token/refresh-access-token.ts:168-198](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/oauth/token/refresh-access-token.ts#L168-L198)

### Resolving User Identity via User Info

The `GET /api/oauth/userinfo` endpoint extracts the bearer token from incoming requests using `getAuthTokenOrThrow`, hashes it, and queries active restricted token records that possess an associated installation ID and have not expired.

```typescript
export async function GET(req: NextRequest) {
  try {
    const accessToken = getAuthTokenOrThrow(req);

    const tokenRecord = await prisma.restrictedToken.findFirst({
      where: {
        hashedKey: await hashToken(accessToken),
        expires: {
          gte: new Date(),
        },
        installationId: {
          not: null,
        },
      },
      select: {
        user: {
          select: {
            id: true,
            name: true,
            image: true,
          },
        },
        project: {
          select: {
            id: true,
            name: true,
            slug: true,
            logo: true,
          },
        },
      },
    });
```
Sources: [apps/web/app/api/oauth/userinfo/route.ts:16-47](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/oauth/userinfo/route.ts#L16-L47)

If the token record is valid, the endpoint constructs an identity response containing the user identifier, display name, user image URL, and workspace details featuring a prefixed workspace identifier.

```typescript
const { user } = tokenRecord;

const userInfo = {
  id: user.id,
  name: user.name,
  image: user.image,
  workspace: {
    id: prefixWorkspaceId(tokenRecord.project.id),
    slug: tokenRecord.project.slug,
    name: tokenRecord.project.name,
    logo: tokenRecord.project.logo,
  },
};

return NextResponse.json(userInfo, {
  headers: CORS_HEADERS,
});
```
Sources: [apps/web/app/api/oauth/userinfo/route.ts:56-72](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/oauth/userinfo/route.ts#L56-L72)

## Client SDK and CLI Integration

### Dub CLI Authentication Flow

The Dub CLI utilizes `@badgateway/oauth2-client` to manage the OAuth 2.0 authorization code flow with PKCE. The client configuration hardcodes the client identifier `dub_app_39527dcc11b452f38bb54a3a1664fd044d7158dfea8abcde`, the authorization endpoint `https://app.dub.co/oauth/authorize`, and the token endpoint `https://api.dub.co/oauth/token`.

```typescript
import { OAuth2Client } from "@badgateway/oauth2-client";

export const oauthClient = new OAuth2Client({
  // TODO: add client id here
  clientId: "dub_app_39527dcc11b452f38bb54a3a1664fd044d7158dfea8abcde",
  authorizationEndpoint: "https://app.dub.co/oauth/authorize",
  tokenEndpoint: "https://api.dub.co/oauth/token",
});
```
Sources: [packages/cli/src/utils/oauth.ts:1-8](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/packages/cli/src/utils/oauth.ts#L1-L8)

When running the `dub login` command, a 64-character code verifier is generated using nanoid, and an authorization URL is requested with scopes `links.read`, `links.write`, and `domains.read`. An interactive browser window opens automatically via the `open` package, pointing to the authorization endpoint on `app.dub.co`.

```typescript
export const login = new Command()
  .name("login")
  .description("Log into the Dub platform")
  .action(async () => {
    try {
      const codeVerifier = getNanoid(64);
      const redirectUri = "http://localhost:4587/callback";

      const authUrl = await oauthClient.authorizationCode.getAuthorizeUri({
        redirectUri,
        codeVerifier,
        scope: ["links.read", "links.write", "domains.read"],
      });

      const spinner = ora("Opening browser for authentication").start();

      await open(authUrl);

      spinner.text = "Waiting for authentication";

      oauthCallbackServer({
        oauthClient,
        redirectUri,
        codeVerifier,
        spinner,
      });
    } catch (error) {
      handleError(error);
    }
```
Sources: [packages/cli/src/commands/login.ts:9-37](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/packages/cli/src/commands/login.ts#L9-L37)

Concurrently, a local HTTP server listens on port `4587` specifically for the `/callback` path redirection from the authorization server.

```typescript
export function oauthCallbackServer({
  oauthClient,
  redirectUri,
  codeVerifier,
  spinner,
}: OAuthCallbackServerProps) {
  const server = http.createServer(async (req, res) => {
    const reqUrl = url.parse(req.url || "", true);

    if (reqUrl.pathname !== "/callback" || req.method !== "GET") {
      res.writeHead(404);
      res.end("Not found");
      return;
    }
```
Sources: [packages/cli/src/api/callback.ts:17-30](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/packages/cli/src/api/callback.ts#L17-L30)

> [!WARNING]
> The local OAuth callback server enforces a strict 5-minute timeout window (`300000` milliseconds), after which the server forcefully closes and terminates the process if authentication has not finished.

Sources: [packages/cli/src/api/callback.ts:80-84](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/packages/cli/src/api/callback.ts#L80-L84)

Once the authorization code is received at the local loopback receiver, the CLI exchanges the code for tokens and persists the session configuration locally.

```typescript
try {
  spinner.text = "Verifying";

  const { accessToken, refreshToken, expiresAt } =
    await oauthClient.authorizationCode.getToken({
      code,
      redirectUri,
      codeVerifier,
    });

  spinner.text = "Configuring";

  const configInfo: DubConfig = {
    access_token: accessToken.trim(),
    refresh_token: refreshToken,
    expires_at: expiresAt,
    domain: "dub.sh",
  };

  await setConfig(configInfo);
  spinner.succeed("Configuration completed");
```
Sources: [packages/cli/src/api/callback.ts:43-63](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/packages/cli/src/api/callback.ts#L43-L63)

### External Client Integration Parameters

External integrations and companion packages interact with Dub's OAuth endpoints by configuring parameters such as response types, grant types, and code challenge methods. The [Stripe App](https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/integrations-subsystem/stripe-app) integration builds authorization URLs with PKCE S256 parameters and exchanges codes directly via `POST` requests to the token endpoint.

| Parameter Name | Value / Type | Purpose |
| :--- | :--- | :--- |
| `response_type` | `"code"` | Specifies the authorization code grant flow |
| `code_challenge_method` | `"S256"` | Cryptographic hashing method for PKCE verification |
| `grant_type` | `"authorization_code"` / `"refresh_token"` | Token endpoint grant type specifier |
| `scope` | `["links.read", "links.write", "domains.read"]` | Requested permission scopes for CLI operations |

Sources: [packages/cli/src/commands/login.ts:20-20](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/packages/cli/src/commands/login.ts#L20-L20), [packages/stripe-app/src/utils/oauth.ts:20-27](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/packages/stripe-app/src/utils/oauth.ts#L20-L27), [packages/stripe-app/src/utils/oauth.ts:51-54](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/packages/stripe-app/src/utils/oauth.ts#L51-L54), [packages/stripe-app/src/utils/oauth.ts:134-136](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/packages/stripe-app/src/utils/oauth.ts#L134-L136)

## Related

- [Authentication](https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/core-architecture/authentication)
- [REST API](https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/administration-and-api/rest-api)


## Sitemap

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