Getting Started with SSO
Descope SSO is authentication middleware for your app. It runs the SAML/OIDC handshake with each customer's identity provider, and you keep your own user database and sessions. After SSO, you validate a Descope token once, look up the user, and start your session the way you already do.
The easiest path has three parts:
- Wire two backend routes with the Descope backend SDK (start and callback). Your frontend only needs a "Sign in with SSO" control, so you don't rebuild the rest of login.
- Connect the customer's IdP with the SSO Setup Suite (or MockSAML when you're just verifying the code).
- Sign in once, end to end.
Note
If you want Descope to own the full login UI and sessions, add the SSO action to a Flow and follow the main Getting Started guide. This page is for keeping your own auth stack.
If your app runs the SSO handshake in the browser or on a device rather than on your server, use the Client SDKs or Mobile SDKs instead.
What You'll Build
- Add start + callback with the backend SDK
- Connect an IdP with the SSO Setup Suite (or MockSAML to test first)
- Test end-to-end
SSO is always configured per tenant (one customer org, with one or more IdP connections). See the SSO overview if that model is new.
Before You Begin
- A Descope project and its Project ID
- One empty tenant for the customer, or a dedicated test tenant. You don't need the IdP configured yet.
- An app or API where you can add two routes
| Term | Meaning |
|---|---|
| Tenant | Your customer's organization in Descope |
| SSO connection | That tenant's SAML or OIDC link to their IdP |
| Descope token | JWTs from a successful exchange. Validate them, then mint your own session |
Step 1: Add SSO with the Backend SDK
Your server starts SSO and exchanges the authorization code, so the code never sits in the browser. After the exchange, validate the Descope token, look up the user in your database, and create your session. This works the same as any other login method you already support.
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 SDK
npm i --save @descope/node-sdknpm i --save @descope/nextjs-sdkpip3 install descopego 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>gem install descopecomposer require descope/descope-phpdotnet add package descopeImport 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)
}
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,
};Start Endpoint: Pass the User's Email
The simplest UX is to have the user type their work email. Your backend calls sso.start with that email, and Descope looks up the tenant whose SSO Domain matches (for example, acme.com resolves to Acme's IdP). You get back the authorization URL for the right tenant.
You can also pass a tenant ID or name when your app already knows the org, whether from a subdomain, a picker, or an invite. For the full option list (loginHint, ssoId, prompt, forceAuthn, and more), see Backend SDK start options.
import DescopeClient from '@descope/node-sdk';
const descopeClient = DescopeClient({ projectId: process.env.DESCOPE_PROJECT_ID });
// POST /auth/sso body: { "email": "alex@acme.com" }
export async function startSso(req, res) {
const email = req.body.email;
const redirectUrl = 'https://app.example.com/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 descopeClient.sso.start(email, redirectUrl, undefined, undefined, undefined, false, email);
if (!resp.ok) {
return res.status(400).json(resp.error);
}
// Redirect the user's browser to the returned URL.
res.redirect(resp.data.url);
}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);
}from descope import DescopeClient
from flask import redirect
import os
descope_client = DescopeClient(project_id=os.environ["DESCOPE_PROJECT_ID"])
# POST /auth/sso body: { "email": "alex@acme.com" }
def start_sso(email: str):
redirect_url = "https://app.example.com/auth/sso/callback"
# Descope matches the email domain to the tenant's SSO Domain and returns
# that tenant's IdP authorization URL. login_hint pre-fills the user at OIDC IdPs.
resp = descope_client.sso.start(tenant=email, return_url=redirect_url, login_hint=email)
# Redirect the user's browser to the returned URL.
return redirect(resp["url"])// GET/POST /auth/sso
func startSso(w http.ResponseWriter, r *http.Request) {
ctx := context.Background()
email := "alex@acme.com" // or a tenant ID/name when you already know the org
returnURL := "https://app.example.com/auth/sso/callback"
// Descope matches the email domain to the tenant's SSO Domain and returns
// that tenant's IdP authorization URL, and writes the redirect to w.
_, err := descopeClient.Auth.SSO.Start(ctx, email, returnURL, r, nil, w)
if err != nil {
// handle error
return
}
// The SDK wrote the redirect to w. The URL is also returned if you want to redirect yourself.
}// GET/POST /auth/sso (email = "alex@acme.com")
SAMLService ss = descopeClient.getAuthenticationServices().getSAMLService();
String returnURL = "https://app.example.com/auth/sso/callback";
// Descope matches the email domain to the tenant's SSO Domain and returns
// that tenant's IdP authorization URL.
String url = ss.start("alex@acme.com", returnURL, null);
// Redirect the user's browser to the returned URL.
return new RedirectView(url);# GET/POST /auth/sso (params[:email] = "alex@acme.com")
# Descope matches the email domain to the tenant's SSO Domain and returns
# that tenant's IdP authorization URL.
url = descope_client.saml_sign_in(
tenant: params[:email],
return_url: 'https://app.example.com/auth/sso/callback'
)
# Redirect the user's browser to the returned URL.
redirect_to url, allow_other_host: true// POST /auth/sso (email = "alex@acme.com")
// Descope matches the email domain to the tenant's SSO Domain and returns
// that tenant's IdP authorization URL.
$response = $descopeSDK->auth->sso->signIn(
"alex@acme.com",
"https://app.example.com/auth/sso/callback"
);
// Redirect the user's browser to the returned URL.
header('Location: ' . $response['url']);
exit;// GET/POST /auth/sso (email = "alex@acme.com")
var email = "alex@acme.com";
string redirectUrl = "https://app.example.com/auth/sso/callback";
// Descope matches the email domain to the tenant's SSO Domain and returns
// that tenant's IdP authorization URL.
var authorizationUrl = await descopeClient.Auth.Sso.Start(tenant: email, redirectUrl: redirectUrl);
// Redirect the user's browser to the returned URL.
return Redirect(authorizationUrl);Use the same redirectUrl you will implement as the callback below.
Callback Endpoint: Exchange the Code
After the IdP, Descope redirects to your callback with ?code=…. Exchange it on the server, then start your session.
// GET /auth/sso/callback?code=...
export async function ssoCallback(req, res) {
const { code } = req.query;
const resp = await descopeClient.sso.exchange(code);
if (!resp.ok) {
return res.status(401).json(resp.error);
}
// resp.data has sessionJwt, refreshJwt, and user.
// Validate the token and start your own session (see below).
const { sessionJwt, refreshJwt, user } = resp.data;
res.redirect('/');
}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 the token and start your own session (see below).
return NextResponse.redirect('https://app.example.com/');
}# GET /auth/sso/callback?code=...
def sso_callback(code: str):
resp = descope_client.sso.exchange_token(code=code)
# resp has sessionJwt, refreshJwt, and user.
# Validate the token and start your own session (see below).
return resp// GET /auth/sso/callback?code=...
ctx := context.Background()
code := r.URL.Query().Get("code")
authInfo, err := descopeClient.Auth.SSO.ExchangeToken(ctx, code, w)
if err != nil {
// handle error
}
// authInfo has the session and refresh JWTs and the user.
// Validate the token and start your own session (see below).// GET /auth/sso/callback?code=...
AuthenticationInfo info = ss.exchangeToken(code);
// info has the session and refresh JWTs and the user.
// Validate the token and start your own session (see below).# GET /auth/sso/callback?code=...
jwt_response = descope_client.saml_exchange_token(code)
# jwt_response has the session and refresh JWTs and the user.
# Validate the token and start your own session (see below).// GET /auth/sso/callback?code=...
$response = $descopeSDK->auth->sso->exchangeToken($code);
// $response has the session and refresh JWTs and the user.
// Validate the token and start your own session (see below).// GET /auth/sso/callback?code=...
var authRes = await descopeClient.Auth.Sso.Exchange(code: code);
// authRes has the session and refresh JWTs and the user.
// Validate the token and start your own session (see below).Validate the Token and Start Your Session
sso.exchange returns Descope session and refresh JWTs along with the user's details. Validate the session JWT before you trust it. Validation checks the signature and expiry against your project's public keys, and it returns the token's claims.
See backend session validation for the exact function in your SDK, and What's in the token for the claims a Descope session JWT carries.
Once the token is valid, you have everything you need to sign the user in on your side. The claims carry the identity basics: the Descope user ID in sub, the user's email, and their tenant memberships and roles. Use those to find the user in your own database, or create the record on first login.
From there, start a session however you already do for other login methods, whether that is setting a session cookie, signing your own JWT, or creating a server-side session. SSO doesn't change this part.
If you need details that aren't in the token (a custom attribute, the full profile, or group memberships you didn't add as claims), fetch them from Descope with the Management SDK, using the sub claim as the user ID. Keep the JWT small and pull extra data only when you need it.
For Next.js, createSdk from @descope/nextjs-sdk/server exposes the same sdk.sso and sdk.management, so the route handlers in the tabs above use the exact same calls.
Step 2: Connect an IdP with the SSO Setup Suite
With routes in place, configure the tenant's SSO connection. Use the SSO Setup Suite, not the raw Console form and not the Management API for this path.
The suite walks through any SAML or OIDC IdP (Okta, Entra, Google Workspace, or generic), attribute/group mapping, SSO Domains, a connection test, and SCIM.
- Open your tenant in the Console.
- Generate a SSO Setup Suite link and open it yourself (or send it to the customer's IT admin).
- Complete IdP setup in the suite.
- Set the SSO Domain (e.g.
acme.com) sosso.start('alex@acme.com')resolves to this tenant. - Run the suite's connection test.
That is the same flow you will use for every real customer later.
Note
To verify your code before you have a real IdP, point a test tenant at MockSAML (https://mocksaml.com/api/saml/metadata), add SSO Domain example.com, then call start with user@example.com. See Mock SAML testing for the walkthrough.
Manual Console fields and Management SDK/API automation are documented elsewhere when you need them: SAML · OIDC · Configure SSO with SDKs.
Step 3: Test End-to-End
Run one full login and confirm each hop works:
- Trigger your start route with an email whose domain matches the tenant's SSO Domain (or the
user@example.comMockSAML user). Your backend should respond with a redirect to the IdP. - Authenticate at the IdP (or MockSAML).
- Confirm the browser lands back on your callback route with a
codequery parameter. - Confirm
sso.exchangesucceeds, your token validates, and your own app session is created. You should now be logged in. - Open Audits and confirm a
LoginSucceededevent for the user.
If the exchange fails, look up the error in the SSO error codes.
So far you have tested SP-initiated login, where the user starts from your app. Users can also start from their identity provider instead. Most IdPs, like Okta, show each connected app as a tile on the user's dashboard, and clicking that tile logs them straight into your app.
This is called IdP-initiated login, and it is easy to forget to test because it never touches your sign-in page. It only works once you have set a Post Authentication Redirect URL, which tells Descope where to send the user afterward.
Configure that URL, then try IdP-initiated login.
Note
Users who first authenticate via SSO become SSO-only. Plan identity merging and SSO Domain association before production if those users might already exist with password/OTP.
Next Steps
- SSO Setup Suite for the full suite, SCIM, and embedding
- Backend SDK reference for
loginHint,ssoId,prompt,forceAuthn, and more - JIT provisioning, mapping, and multi-SSO
- Launch checklist and troubleshooting