> ## Documentation Index
> Fetch the complete documentation index at: https://docs.prelude.so/llms.txt
> Use this file to discover all available pages before exploring further.

# SSO Login

> Add single sign-on across your own applications by using Prelude Auth as the OAuth 2.0 authorization server, then sign clients in with the Web SDK.

Turn Prelude Auth into your own OAuth 2.0 authorization server so any of your applications can sign users in against your existing Prelude Auth users — a "Sign in with us" button for your product suite. You host the login and consent screens once; every client app then runs a standard authorization-code + PKCE flow against them, with no extra identity provider to run.

This is the same authorization-server machinery that powers [MCP Login](/auth/documentation/integration-guide/mcp), pointed at your **own** applications instead of MCP clients.

<Note>
  This is **not** [Enterprise SSO (OIDC)](/auth/documentation/frontend-sdks/web/enterprise-oidc) or [social login](/auth/documentation/integration-guide/social-login/introduction). Those make Prelude the OAuth **client** of an external identity provider (Okta, Google…). Here Prelude is the authorization **server**, and your app is the client.
</Note>

## How it works

There are two sides to build, and this guide covers both:

* **The authorization frontend** — the login and consent screens Prelude redirects to. Prelude drives the OAuth protocol; your UI authenticates the user and collects consent, then hands the browser back with an authorization `code`. This is exactly what the Prelude Dashboard does for MCP clients today.
* **The client app** — the application asking users to sign in. It uses the new `PrldOAuth2Client` in the [Web SDK](/auth/documentation/frontend-sdks/web/introduction) to start the flow and exchange the `code` for a session.

A login looks like this:

<Steps>
  <Step title="Authorize">
    The client app calls `PrldOAuth2Client.initiate()` and redirects the browser to `/v1/session/oauth/authorize` with PKCE. Prelude persists the request and `302`s to your login UI with an `?oauth_req=<oar_…>` parameter.
  </Step>

  <Step title="Login">
    Your sign-in route reads `oauth_req`, authenticates the user with any method your app supports (OTP, password, passkey, social, SAML), then calls `continueOAuth2(oauthReq)`. Prelude returns the consent URL to navigate to.
  </Step>

  <Step title="Consent">
    Your consent route fetches the pending request with `getOAuth2Pending(oauthReq)` to display the client name and requested scopes, then submits the user's decision with `decideOAuth2(oauthReq, approve)`. Prelude returns the client `redirect_uri` — with a one-time `code` and `state` on approval, or `error=access_denied` on denial.
  </Step>

  <Step title="Token">
    The browser lands back on the client app's `redirect_uri`. The client calls `handleCallback()`, which verifies `state`, exchanges the `code` at `/v1/session/oauth/token` with its PKCE `code_verifier`, and persists the tokens so a `PrldSessionClient` on the same domain picks up the session.
  </Step>
</Steps>

The issued session is a **child** of the user's browser session. Its scopes are the intersection of the requested scopes and the parent session's scopes (or the parent's full set when the client requests the `prld:oauth:inherit` scope). A session minted through the OAuth flow cannot itself start another OAuth flow — the chain is capped at one level.

## Prerequisites

Before you start, make sure you have:

* A Prelude account with access to Prelude Auth
* An **Application ID** (`appID`) — see [Applications](/auth/documentation/applications)
* Your **Management API key** for backend calls
* A verified [custom domain](/auth/documentation/domain-names), or your default `${APP_ID}.session.prelude.dev` host — the authorization-server endpoints are anchored to this **auth domain**
* A login UI built with the [Web SDK](/auth/documentation/frontend-sdks/web/introduction) that can host the sign-in and consent screens

## Configure Prelude as your authorization server

Enable the OAuth server on your app and point it at your login UI with a single `PUT`. `default_provider_url` is the origin Prelude sends the browser to: it `302`s to `<default_provider_url>/sign-in` from `/authorize`, and to `<default_provider_url>/oauth/consent` after login.

```bash theme={null}
curl -X PUT https://api.prelude.dev/v2/session/apps/${APP_ID}/oauth \
  -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "default_provider_url": "https://auth.example.com",
    "registration": {
      "redirect_uri_allowlist": [
        "https://app.example.com/callback",
        "http://localhost/*"
      ]
    }
  }'
```

For first-party SSO you typically know your client apps ahead of time, so register each one explicitly instead of enabling Dynamic Client Registration. This returns the `client_id` (an `oac_…` identifier) the client app needs:

```bash theme={null}
curl -X POST https://api.prelude.dev/v2/session/apps/${APP_ID}/oauth/clients \
  -H "Authorization: Bearer ${MANAGEMENT_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "client_name": "Example App",
    "redirect_uris": ["https://app.example.com/callback"],
    "scopes": ["openid"],
    "provider_url": "https://auth.example.com"
  }'
```

The `client_name` you set here is what the consent screen shows the user. See the [OAuth Server Config API](/auth/api-reference/management/config/oauth-server/update-oauth-server-config) reference for the full configuration schema — including DCR and CIMD if you want clients to self-register, as covered in the [MCP Login](/auth/documentation/integration-guide/mcp) guide.

## Build the authorization frontend

Prelude handles the OAuth protocol but hands the browser to **your** login UI to authenticate the user and collect consent. Build two routes at the origin you set as `default_provider_url`. Both use a standard [Web SDK](/auth/documentation/frontend-sdks/web/introduction) session client:

```javascript theme={null}
import { PrldSessionClient } from "@prelude.so/js-sdk";

const client = new PrldSessionClient({
  domain: "{app_id}.session.prelude.dev",
});
```

<Steps>
  <Step title="Sign-in route (/sign-in)">
    Prelude redirects here with an `?oauth_req=<oar_…>` parameter. Authenticate the user with any method your app supports, then resume the flow with `continueOAuth2`. Prelude returns the consent URL to navigate to.

    ```javascript theme={null}
    const params = new URLSearchParams(window.location.search);
    const oauthReq = params.get("oauth_req");

    // ...sign the user in (OTP, password, passkey, social, SAML)...

    if (oauthReq) {
      // Resume the OAuth flow: attach the now-authenticated user to the request.
      const { redirectUrl } = await client.continueOAuth2(oauthReq);
      window.location.assign(redirectUrl); // -> your /oauth/consent route
    }
    ```

    If the user already has a live session when they land here, you can skip straight to `continueOAuth2`.
  </Step>

  <Step title="Consent route (/oauth/consent)">
    Prelude redirects here with the same `?oauth_req=`. This route requires a signed-in session — if there is none, send the user to `/sign-in?oauth_req=<oar_…>` first, preserving the parameter. Fetch the pending request to render consent, then submit the decision.

    ```javascript theme={null}
    const params = new URLSearchParams(window.location.search);
    const oauthReq = params.get("oauth_req");

    // What the consent screen should display.
    const pending = await client.getOAuth2Pending(oauthReq);
    // pending = { clientId, clientName, redirectUri, scopes }

    // ...render "clientName wants access" + the scopes, with Allow / Deny buttons...

    const approve = true; // false when the user denies
    const { redirectUrl } = await client.decideOAuth2(oauthReq, approve);

    // Full-page navigation: redirectUrl points at the client app, carrying
    // ?code=&state= on approval (or ?error=access_denied on denial).
    window.location.assign(redirectUrl);
    ```
  </Step>
</Steps>

<Note>
  The `continue`, `pending`, and `decision` calls run against the user's live session and are bound to it with [DPoP](https://datatracker.ietf.org/doc/html/rfc9449). The Web SDK manages the session and the DPoP proof for you — you do not construct these requests by hand.
</Note>

## Build the client app

The application signing users in acts as a public OAuth client. The Web SDK ships `PrldOAuth2Client` for exactly this: `initiate()` builds the authorize URL (generating and storing the PKCE `code_verifier` and CSRF `state`), and `handleCallback()` validates the returned `state`, exchanges the `code`, and persists the session.

```javascript theme={null}
import { PrldOAuth2Client } from "@prelude.so/js-sdk";

const oauth = new PrldOAuth2Client({
  domain: "{app_id}.session.prelude.dev",
  clientId: "oac_01jqebhswje1ka1z7ahr9rfsgt", // from the client you registered
  redirectURI: "https://app.example.com/callback",
  scopes: ["openid"],
});
```

<Steps>
  <Step title="Start the flow">
    Call `initiate()` and send the browser to the returned `authorizationURL`. The PKCE verifier and `state` are persisted for you; `initiate()` does not navigate, so you do it.

    ```javascript theme={null}
    const { authorizationURL } = await oauth.initiate();
    window.location.href = authorizationURL;
    ```

    `initiate()` also accepts per-call `{ scopes, state }` if you want to override the constructor defaults or supply your own `state`.
  </Step>

  <Step title="Handle the callback">
    At your `redirectURI`, pass the query parameters to `handleCallback()`. It throws `PrldErrors.OAuth2` on a provider error, a `state` mismatch, or a failed exchange.

    ```javascript theme={null}
    import { PrldSessionClient, PrldErrors } from "@prelude.so/js-sdk";

    const params = new URLSearchParams(window.location.search);

    try {
      await oauth.handleCallback({
        code: params.get("code") ?? undefined,
        state: params.get("state") ?? undefined,
        error: params.get("error") ?? undefined,
        error_description: params.get("error_description") ?? undefined,
      });

      // Tokens are persisted keyed by domain, just like a session login — so a
      // session client on the same domain now sees the authenticated user.
      const client = new PrldSessionClient({ domain: "{app_id}.session.prelude.dev" });
      const { user } = await client.refresh();
    } catch (err) {
      if (err instanceof PrldErrors.OAuth2) {
        // err.code is the OAuth error, e.g. access_denied or invalid_grant
        console.error(err.code);
      }
    }
    ```
  </Step>
</Steps>

<Note>
  `handleCallback()` returns the raw token result (`{ accessToken, tokenType, expiresIn, refreshToken?, scope? }`) if you want to inspect it, but you rarely need to — it persists the session for you, so continue with a `PrldSessionClient` as you would after any other login.
</Note>

## What's next?

<CardGroup cols={2}>
  <Card title="Web SDK" icon="js" href="/auth/documentation/frontend-sdks/web/introduction">
    Build the sign-in and consent screens your login UI hosts.
  </Card>

  <Card title="MCP Login" icon="plug" href="/auth/documentation/integration-guide/mcp">
    The same authorization server, for MCP clients — including DCR and CIMD self-registration.
  </Card>

  <Card title="OAuth Server API" icon="code" href="/auth/api-reference/frontend/oauth-server-authorize">
    The raw authorize, token, continue, pending, and decision endpoints.
  </Card>

  <Card title="Groups & scopes" icon="layer-group" href="/auth/documentation/groups">
    Control which scopes a session — and its OAuth children — can carry.
  </Card>
</CardGroup>
