Single Sign-On (SSO) with Backend SDKs

Note

This is the preferred way to add SSO when you keep your own sessions: your server starts SSO and exchanges the code, so the frontend mostly redirects. For the full walkthrough (configure a tenant, then wire start + callback), see Getting Started with SSO.

If you're building with Descope Flows instead, start with the Flows quickstart.

Use the backend SDK when you want your server to drive the SSO handshake. Your backend starts the login, exchanges the authorization code (so it never sits in the browser), and validates the session afterward.

SSO is configured per tenant (SAML or OIDC). See tenant management for how to set that up.

Note

Before the code below will work, enable SSO at the project level and configure a SAML or OIDC connection for at least one tenant. For a full walkthrough, see Getting Started with SSO, or test without your own IdP.

How the Flow Works

There are three steps:

  1. Start SSO. Your backend calls sso.start and gets back a URL. Redirect the user's browser to it. Descope sends them to the tenant's identity provider, and after they authenticate, the browser returns to your redirect_url with a one-time code.
  2. Exchange the code. Your backend calls sso.exchange with that code and receives the session and refresh tokens.
  3. Validate the session. Set the session as a cookie (or return it to your client), then validate it on later requests.

Descope acts as the SAML/OIDC service provider toward the identity provider, so you don't build the federation yourself.

FrontendYour BackendDescopeIdentity Providersign in with SSOsso.start(tenant)authorization URLredirect to IdPauthenticateredirect with codeGET /callback?codesso.exchange(code)session + refresh tokensvalidate JWT, set your sessionyour app session

Your backend owns the handshake: it starts SSO, exchanges the code server-side, and validates the session. The browser only talks to the IdP and your callback.

Install and Initialize

Install SDK

Terminal
npm i --save @descope/nextjs-sdk

Import and initialize SDK

lib/descope.ts
import { createSdk } from '@descope/nextjs-sdk/server';

// createSdk wraps the backend SDK, so sdk.sso and sdk.management are available.
export const sdk = createSdk({
  projectId: process.env.NEXT_PUBLIC_DESCOPE_PROJECT_ID!,
});

Step 1: Start SSO

When the user clicks sign in with SSO, call the start function from your backend. It returns a URL you redirect the browser to, which kicks off login with the tenant's identity provider.

Identifying the Tenant (tenantIdOrEmail)

The first argument is a tenant ID, a tenant name, or the user's email:

  • Email: Descope matches the email domain to a tenant SSO Domain and starts that tenant's SSO connection. For example, the user enters alex@acme.com. You call sso.start('alex@acme.com', …), and Descope returns the Acme IdP URL.
  • Tenant ID or name: Skip the domain lookup when your app already knows the org, whether from a subdomain, an org picker, or an invite.

Configure SSO Domains before relying on email. See Tenant identification methods. For several IdPs under one tenant, also set domains per connection or pass ssoId (Multiple SSO providers).

Start Options

ParameterApplies toPurpose
tenantIdOrEmail (required)AllTenant ID, tenant name, or email for SSO Domain lookup
redirectUrl / return_urlAllWhere Descope sends the browser after IdP auth, with ?code=…
loginHint / login_hintOIDC (often); some SAML IdPsHint to the IdP about which account to use, usually the same email the user typed
ssoId / sso_idMulti-SSO tenantsSelect a specific SSO configuration on the tenant
promptOIDCIdP prompt (login, consent, none, …). See Prompt.
forceAuthn / force_authnSAMLRequire fresh authentication at the IdP even if a session exists
enforceInitiatedEmailSAML & OIDCRequire the IdP's response to match the email you started with; blocks sign-in otherwise. See below
loginOptionsAllstepup, mfa, customClaims, templateOptions
refresh / MFA tokenWhen step-up or MFACurrent refresh JWT so Descope can elevate the existing session

Node / Web JS positional order for sso.start:

tenantIdOrEmail, redirectUrl, loginOptions, token, ssoId, forceAuthn, loginHint, enforceInitiatedEmail.

Python uses named args (tenant, return_url, login_options, prompt, sso_id, login_hint, force_authn, …). Check your SDK version for exact names.

Note

enforceInitiatedEmail can be used in the Node.js/TypeScript backend SDK and the client-side JS SDKs (React, Web Component, Web JS). If you're using a different SDK that doesn't support it, call the REST API's initiatedEmail query parameter directly instead (see below).

Verify the IdP Email Matches the Initiated Email

If a user starts SSO with their own email (SP-initiated), you can require Descope to confirm the identity provider authenticated that same person before it completes sign-in. The IdP session in the user's browser might belong to a different account than the one they typed into your app.

Pass enforceInitiatedEmail: true (or initiatedEmail=<email> on the REST API) alongside the email you're starting with:

const email = 'alex@acme.com';

const resp = await descopeClient.sso.start(
  email,        // SSO Domain → tenant + IdP URL, and the value enforceInitiatedEmail checks against
  redirectUrl,
  undefined,    // loginOptions
  undefined,    // refresh token
  undefined,    // ssoId
  false,        // forceAuthn
  email,        // loginHint
  true,         // enforceInitiatedEmail
);

REST equivalent: add initiatedEmail=alex@acme.com to the Start SSO query parameters.

This check:

  • Compares the email you passed in against the SAML email attribute or NameID (or, for OIDC, the email claim), case-insensitively.
  • Fails sso.exchange with E062020 and blocks sign-in if they don't match. See SSO troubleshooting.
  • This only applies when SSO is started with an email address. It doesn't apply to SSO started by tenant ID or tenant name, or to IdP-initiated logins, as in both cases there's no email to compare against.

The same option is available from a Descope Flow via the SSO action's Verify initiated email matches IdP response toggle. See SSO with Flows.

Example: Email and Login Hint (OIDC-Friendly)

const email = 'alex@acme.com';
const redirectUrl = 'https://app.example.com/auth/sso/callback';

const resp = await descopeClient.sso.start(
  email,        // SSO Domain → tenant + IdP URL
  redirectUrl,
  undefined,    // loginOptions
  undefined,    // refresh token
  undefined,    // ssoId
  false,        // forceAuthn
  email,        // loginHint
);
url = descope_client.sso.start(
    tenant="alex@acme.com",
    return_url="https://app.example.com/auth/sso/callback",
    login_hint="alex@acme.com",
)

Example: Known Tenant with a Multi-SSO Profile

url = descope_client.sso.start(
    tenant="acme-tenant-id",
    return_url="https://app.example.com/auth/sso/callback",
    sso_id="contractors",  # which IdP on that tenant
)

REST equivalent: Start SSO (tenant, redirectUrl, loginHint, forceAuthn, prompt, …).

Start Code Samples

app/api/auth/sso/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { sdk } from '@/lib/descope';

// POST /api/auth/sso  body: { "email": "alex@acme.com" }
export async function POST(req: NextRequest) {
  const { email } = await req.json();
  const redirectUrl = 'https://app.example.com/api/auth/sso/callback';

  // Descope matches the email domain to the tenant's SSO Domain and returns
  // that tenant's IdP authorization URL. loginHint pre-fills the user at OIDC IdPs.
  const resp = await sdk.sso.start(email, redirectUrl, undefined, undefined, undefined, false, email);
  if (!resp.ok) {
    return NextResponse.json(resp.error, { status: 400 });
  }

  // Redirect the user's browser to the returned URL.
  return NextResponse.redirect(resp.data.url);
}

Step 2: Exchange the Code

After the user authenticates with the IdP, they're sent back to the redirect_url you passed to sso.start. Pull the code query parameter from that URL and exchange it for tokens:

app/api/auth/sso/callback/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { sdk } from '@/lib/descope';

// GET /api/auth/sso/callback?code=...
export async function GET(req: NextRequest) {
  const code = req.nextUrl.searchParams.get('code');
  if (!code) {
    return NextResponse.json({ error: 'missing code' }, { status: 400 });
  }

  const resp = await sdk.sso.exchange(code);
  if (!resp.ok) {
    return NextResponse.json(resp.error, { status: 401 });
  }

  // resp.data has sessionJwt, refreshJwt, and user. Validate it, then start your session.
  return NextResponse.redirect('https://app.example.com/');
}

Step 3: Validate the Session

Once you have the tokens, validate the user session on later requests. Descope covers session timeouts, logout, and related options. See backend session validation for details and sample code.

Was this helpful?

On this page