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/node-sdk
Terminal
npm i --save @descope/nextjs-sdk
Terminal
pip3 install descope
Terminal
go get github.com/descope/go-sdk
// Include the following in your `pom.xml` (Maven)
<dependency>
    <artifactId>java-sdk</artifactId>
    <groupId>com.descope</groupId>
    <version>sdk-version</version> // Check https://github.com/descope/descope-java/releases for the latest versions
</dependency>
Terminal
gem install descope
Terminal
composer require descope/descope-php
Terminal
dotnet add package descope

Import and initialize SDK

import DescopeClient from '@descope/node-sdk';
try{
    //  baseUrl="<URL>" // When initializing the Descope client, you can also configure the baseUrl ex: https://auth.company.com  - this is useful when you utilize a custom domain within your Descope project.
    const descopeClient = DescopeClient({ projectId: '__ProjectID__' });
} catch (error) {
    // handle the error
    console.log("failed to initialize: " + error)
}
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!,
});
from descope import (
    REFRESH_SESSION_TOKEN_NAME,
    SESSION_TOKEN_NAME,
    AuthException,
    DeliveryMethod,
    DescopeClient,
    AssociatedTenant,
    RoleMapping,
    AttributeMapping,
    LoginOptions
)
try:
    # You can configure the baseURL by setting the env variable Ex: export DESCOPE_BASE_URI="https://auth.company.com  - this is useful when you utilize custom domain within your Descope project."
    descope_client = DescopeClient(project_id='__ProjectID__')
except Exception as error:
    # handle the error
    print ("failed to initialize. Error:")
    print (error)
import "github.com/descope/go-sdk/descope"
import "github.com/descope/go-sdk/descope/client"

// Utilizing the context package allows for the transmission of context capabilities like cancellation
//      signals during the function call. In cases where context is absent, the context.Background()
//      function serves as a viable alternative.
//      Utilizing context within the Descope GO SDK is supported within versions 1.6.0 and higher.
import (
	"context"
)

// DescopeBaseURL // within the client.Config, you can also configure the baseUrl ex: https://auth.company.com  - this is useful when you utilize a custom domain within your Descope project.

descopeClient, err := client.NewWithConfig(&client.Config{ProjectID:"__ProjectID__"})
if err != nil {
    // handle the error
    log.Println("failed to initialize: " + err.Error())
}
import com.descope.client.Config;
import com.descope.client.DescopeClient;

var descopeClient = new DescopeClient(Config.builder().projectId("__ProjectID__").build());
require 'descope'

@project_id = ENV['__ProjectID__']
@client = Descope::Client.new({ project_id: @project_id})
require 'vendor/autoload.php';
use Descope\SDK\DescopeSDK;
 
$descopeSDK = new DescopeSDK([
    'projectId' => $_ENV['__ProjectID__'],
]);
// appsettings.json

{
  "Descope": {
    "ProjectId": "__ProjectID__",
    "ManagementKey": "DESCOPE_MANAGEMENT_KEY"
  }
}

// Program.cs

using Descope;
using Microsoft.Extensions.Configuration;

// ... In your setup code
var config = new ConfigurationBuilder()
  .AddJsonFile("appsettings.json")
  .Build();

var descopeProjectId = config["Descope:ProjectId"];
var descopeManagementKey = config["Descope:ManagementKey"];

var descopeConfig = new DescopeConfig(projectId: descopeProjectId);
var descopeClient = new DescopeClient(descopeConfig)
{
    ManagementKey = descopeManagementKey,
};

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
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 (and optionally enforceInitiatedEmail in newer SDKs).

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

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);
}
// Args:
//   tenantIdOrEmail: tenant ID, tenant name, OR user email (email → SSO Domain lookup)
const tenant_name_id_or_email = "alex@acme.com"
//   redirect_url: URL to return to after successful authentication with the SSO identity provider. You need to implement this page to access the token and finish oauth process (token exchange). The token arrives as a query parameter named 'code'.
const redirect_url = "https://auth.company.com/token_exchange"
//    loginOptions (LoginOptions): this allows you to configure behavior during the authentication process.
const loginOptions = {
      "stepup": false,
      "mfa": false,
      "customClaims": {"claim": "Value1"},
      "templateOptions": {"option": "Value1"}
    }
//    refreshToken (optional): the user's current refresh token in the event of stepup/mfa
//    ssoId (optional): SSO configuration ID when the tenant has multiple IdPs
//    forceAuthn (optional): SAML only; force re-auth at the IdP
//    loginHint (optional): OIDC; hint or pre-fill the user at the IdP (often the same email)

const resp = await descopeClient.sso.start(
  tenant_name_id_or_email,
  redirect_url,
  loginOptions,
  undefined,           // refreshToken
  undefined,           // ssoId
  false,               // forceAuthn
  tenant_name_id_or_email.includes("@") ? tenant_name_id_or_email : undefined, // loginHint
);
if (!resp.ok) {
  console.log("Failed to start sso auth")
  console.log("Status Code: " + resp.code)
  console.log("Error Code: " + resp.error.errorCode)
  console.log("Error Description: " + resp.error.errorDescription)
  console.log("Error Message: " + resp.error.errorMessage)
}
else {
  const url = resp.data.url
  console.log("Successfully started sso auth. URL: " + url)
}
# Args:
#   tenant: tenant ID, tenant name, OR user email (email → SSO Domain lookup)
tenant_name_id_or_email = "alex@acme.com"
#   redirect_url: URL to return to after successful authentication with the SSO identity provider. You need to implement this page to access the token and finish oauth process (token exchange). The token arrives as a query parameter named 'code'.
redirect_url = "https://auth.company.com/token_exchange"
#   login_options (LoginOptions): this allows you to configure behavior during the authentication process.
login_options = {
      "stepup": False,
      "mfa": False,
      "custom_claims": {"claim": "Value1"},
      "template_options": {"option": "Value1"}
    }
#   refresh_token (optional): the user's current refresh token in the event of stepup/mfa
#   prompt (optional): OIDC-only prompt value (e.g., "login", "consent")
#   sso_id (optional): SSO configuration ID to use
#   login_hint (optional): Hint about the user's login identifier (OIDC)
#   force_authn (optional): SAML-only value that can force authentication even if the user already has a session

try:
  resp = descope_client.sso.start(
      tenant=tenant_name_id_or_email,
      return_url=redirect_url,
      login_options=login_options,
      prompt=None,
      sso_id=None,
      login_hint=tenant_name_id_or_email if "@" in tenant_name_id_or_email else None,
      force_authn=False
  )
  print("Successfully started sso auth. URL: ")
  print(resp)
except AuthException as error:
  print("Failed to start sso auth")
  print("Status Code: " + str(error.status_code))
  print("Error: " + str(error.error_message))
// Args:
//  ctx: context.Context - Application context for the transmission of context capabilities like
//        cancellation signals during the function call. In cases where context is absent, the context.Background()
//        function serves as a viable alternative.
//        Utilizing context within the Descope GO SDK is supported within versions 1.6.0 and higher.
ctx := context.Background()
//  tenant: Name of the tenant that the user is authenticating to. The tenant ID is assigned to tenant at the time of creation.
tenant = "xxxx"
//  returnURL: url for redirecting the user after authentication with social oauth provider. This value will override the value in the console settings. You need to implement this page to access the token and finish oauth process (token exchange). The token arrives as a query parameter named 'code'.
returnURL := "https://auth.company.com/token_exchange"
//  r: HttpRequest for the update call. This request should contain refresh token for the authenticated user.
//    loginOptions: this allows you to configure behavior during the authentication process.
loginOptions := &descope.LoginOptions{
    Stepup: true,
    MFA: true,
    CustomClaims: map[string]any{}{"test": "testClaim"},
    TemplateOptions: map[string]any{"option": "Value1"}
  }
//  w: ResponseWriter to update with correct redirect url. You can return this to your client for redirect.

redirectURL, err:= descopeClient.Auth.SSO.Start(ctx, tenant, returnURL, r, loginOptions, w)
if (err != nil){
  fmt.Println("Failed to initialize SSO flow: ", err)
} else {
  fmt.Println("Successfully started SSO flow: ", redirectURL)
}
// Choose which tenant to log into
// Redirect the user to the returned URL to start the SSO redirect chain
SAMLService ss = descopeClient.getAuthenticationServices().getSAMLService();

try {
    String returnURL = "https://my-app.com/handle-sso";
    String url = ss.start("my-tenant-ID", returnURL, loginOptions);
} catch (DescopeException de) {
    // Handle the error
}
descope_client.saml_sign_in(
    tenant: 'my-tenant-ID', # Choose which tenant to log into
    return_url: 'https://my-app.com/handle-saml', # Can be configured in the console instead of here
    prompt: 'custom prompt here'
)
$response = $descopeSDK->auth->sso->signIn(
    "tenant",
    "https://example.com/callback",
    "prompt",
    true,
    true,
    ["custom" => "claim"],
    "ssoAppId"
);
print_r($response);
// Args:
//   tenant (string): ID of the tenant that the user is authenticating to. The tenant ID is assigned to tenant at the time of creation.
var tenant = "my-tenant-ID";
//   redirectUrl (string?): An optional parameter to generate the SSO link. If not given, the project default will be used.
string? redirectUrl = "https://my-app.com/handle-saml";
//   prompt (string?): OIDC-only prompt value (e.g., "login", "consent").
string? prompt = null;   
//   forceAuthn (bool?): SAML-only value that can force auth even if the user already has a session.
bool? forceAuthn = false;
//   loginOptions (LoginOptions?): Step-up / MFA / custom claims.
LoginOptions? loginOptions = new LoginOptions
{
    StepupRefreshJwt = null, // or an existing refresh JWT for step-up
    MfaRefreshJwt = null, // or an existing refresh JWT for MFA
    CustomClaims = new Dictionary<string, object>
    {
        ["attribute1"] = "value"
    }
};

try
{
    var redirectUrl = await descopeClient.Auth.Sso.Start(tenant: tenant, redirectUrl: redirectUrl, prompt: prompt, forceAuthn: forceAuthn, loginOptions: loginOptions);
}
catch (DescopeException ex)
{
    // Handle the error
}

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/');
}
// Args:
//   code: code extracted from the url after user is redirected to redirect_url. The code is in the url as a query parameter "code" of the page.
const code = "xxxxx"

const resp = await descopeClient.sso.exchange(code);
if (!resp.ok) {
  console.log("Failed to verify sso code")
  console.log("Status Code: " + resp.code)
  console.log("Error Code: " + resp.error.errorCode)
  console.log("Error Description: " + resp.error.errorDescription)
  console.log("Error Message: " + resp.error.errorMessage)
}
else {
  console.log("Successfully verified sso code.")
}
# Args:
#   code: code extracted from the url after user is redirected to redirect_url. The code is in the url of the page.
code = "xxxxx"

try:
  resp = descope_client.sso.exchange_token(code=code)
  print ("Successfully verified sso code.")
  print (resp)
except AuthException as error:
  print ("Failed to verify sso code")
  print ("Status Code: " + str(error.status_code))
  print ("Error: " + str(error.error_message))
// Args:
//   ctx: context.Context - Application context for the transmission of context capabilities like
//        cancellation signals during the function call. In cases where context is absent, the context.Background()
//        function serves as a viable alternative.
//        Utilizing context within the Descope GO SDK is supported within versions 1.6.0 and higher.
ctx := context.Background()
//   code (string): code should be extracted from the redirect URL of OAuth authentication from the query parameter `code`.
code := "xxxxxx"
//   w: ResponseWriter to update with correct session details. You can return this to your client for setting the cookies which are used for session validation

authInfo, err := descopeClient.Auth.SSO.ExchangeToken(ctx, code, w)
if (err != nil){
  fmt.Println("Failed to verify sso code: ", err)
} else {
  fmt.Println("Successfully verified sso code: ", authInfo)
}
// The optional `w http.ResponseWriter` adds the session and refresh cookies to the response automatically.
// Otherwise they're available via authInfo
SAMLService ss = descopeClient.getAuthenticationServices().getSAMLService();

try {
    String url = ss.exchangeToken(code);
} catch (DescopeException de) {
    // Handle the error
}
jwt_response = descope_client.saml_exchange_token(code)
 session_token = jwt_response[Descope::Mixins::Common::SESSION_TOKEN_NAME].fetch('jwt')

 refresh_token = jwt_response[Descope::Mixins::Common::REFRESH_SESSION_TOKEN_NAME].fetch('jwt')
$response = $descopeSDK->auth->sso->exchangeToken("code");
print_r($response);
// Args:
//   code (string): code extracted from the url after user is redirected to redirect_url. The code is in the url as a query parameter "code" of the page.
var code = "authorization-code";

try
{
    var authRes = await descopeClient.Auth.Sso.Exchange(code: code);
}
catch (DescopeException ex)
{
    // Handle the error
}

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