Skip to content
Scalekit Docs
Talk to an Engineer Dashboard

Handle the callback and find the user

Build the Modular SSO callback: check the state and errors, exchange the code, find or create the user in your database and start your app's session.

At the end of an SSO login, Scalekit redirects the user to your callback URL. This page builds that endpoint for production. By the end, your app:

  • starts each login with a state value and rejects responses it didn’t ask for
  • turns failed logins into a clear message
  • finds or creates the user in your database, keyed on a stable ID rather than an email address
  • starts your own session
  • Complete the quickstart: your environment is in Modular Auth mode, and your callback URL is registered under Authentication > Redirect URLs > Allowed callback URLs.

  • Add these to your .env file. REDIRECT_URI is your registered callback URL. SESSION_SECRET signs your app’s session cookie; use a long random value.

    .env
    SCALEKIT_ENVIRONMENT_URL=<your-environment-url>
    SCALEKIT_CLIENT_ID=<your-client-id>
    SCALEKIT_CLIENT_SECRET=<your-client-secret>
    REDIRECT_URI=http://localhost:3000/auth/callback
    SESSION_SECRET=<a-long-random-value>
  1. Terminal window
    npm install @scalekit-sdk/node express express-session dotenv
  2. The example has two endpoints:

    • /auth/sso starts a login. It creates a random state value, stores it in the user’s browser session and passes it in the authorization URL. It takes the customer’s organization ID; in your app, look it up from your records, for example by the email domain the user entered.
    • /auth/callback finishes it. It checks the response, exchanges the code for the user’s profile, finds or creates the user and starts your session.
    server.mjs
    import crypto from 'node:crypto';
    import 'dotenv/config';
    import express from 'express';
    import session from 'express-session';
    import { ScalekitClient } from '@scalekit-sdk/node';
    const scalekit = new ScalekitClient(
    process.env.SCALEKIT_ENVIRONMENT_URL,
    process.env.SCALEKIT_CLIENT_ID,
    process.env.SCALEKIT_CLIENT_SECRET,
    );
    const redirectUri = process.env.REDIRECT_URI;
    const app = express();
    app.use(
    session({ secret: process.env.SESSION_SECRET, resave: false, saveUninitialized: false }),
    );
    // Your users table. Replace with your database.
    const users = new Map();
    function findOrCreateUser({ organizationId, idpUserId, email, name }) {
    const key = `${organizationId}:${idpUserId}`;
    let user = users.get(key);
    if (!user) {
    user = { id: crypto.randomUUID(), organizationId, idpUserId };
    users.set(key, user);
    }
    // Keep the profile current: email and name can change at the IdP
    Object.assign(user, { email, name });
    return user;
    }
    // Start an SSO login for one of your customers
    app.get('/auth/sso', (req, res) => {
    const state = crypto.randomBytes(16).toString('hex');
    req.session.ssoState = state;
    const url = scalekit.getAuthorizationUrl(redirectUri, {
    organizationId: req.query.organization_id,
    state,
    });
    res.redirect(url);
    });
    app.get('/auth/callback', async (req, res, next) => {
    const { code, state, error, error_description } = req.query;
    const expectedState = req.session.ssoState;
    delete req.session.ssoState;
    if (error) {
    console.warn(`SSO login failed: ${error}: ${error_description}`);
    return res.status(401).send('Sign-in failed');
    }
    if (!state || state !== expectedState) {
    return res.status(400).send('Sign-in failed: unexpected state');
    }
    let result, claims;
    try {
    result = await scalekit.authenticateWithCode(code, redirectUri);
    // The ID token's oid claim is the user's organization
    claims = await scalekit.validateToken(result.idToken);
    } catch (err) {
    console.warn('Code exchange failed:', err.message);
    return res.status(401).send('Sign-in failed');
    }
    const profile = result.user;
    const user = findOrCreateUser({
    organizationId: claims.oid,
    idpUserId: profile.id.slice(profile.id.indexOf(';') + 1),
    email: profile.email,
    name: [profile.givenName, profile.familyName].filter(Boolean).join(' '),
    });
    // Start a fresh session for the user, so a session ID set before login can't be reused
    req.session.regenerate((err) => {
    if (err) return next(err);
    req.session.userId = user.id;
    res.redirect('/');
    });
    });
    app.listen(3000, () => console.log('Listening on http://localhost:3000'));

    Run it with node server.mjs.

  3. The callback handles three outcomes, in this order:

    • An error parameter. The login failed at Scalekit or at the identity provider (IdP). Log error and error_description, and show the user a short message rather than the raw text. The codes and their fixes are in the SSO error reference.
    • A state that doesn’t match. The response isn’t for a login this browser started, for example a forged link. Reject it. The stored value is removed on each callback, so the same response can’t be used twice.
    • A code. Exchange it once, with the same redirect URI you passed to the authorization URL. The SDK checks the ID token’s signature and returns the user’s profile.
  4. Key each user on two values, not on their email:

    • The organization ID, which says which customer the user belongs to.
    • The user’s ID at the IdP: the part of the profile’s id (the ID token’s sub) after the ;. The part before it is the connection ID, which changes if your customer deletes their SSO connection and creates a new one.

    Email addresses change when a user is renamed, and two customers’ IdPs can each assert any address, so an email alone can’t safely identify a user. Store the email and name, and update them at each login. Some IdPs send a changeable value, such as an email address, as the user’s ID; see Choose a stable user identifier, which covers Microsoft Entra ID.

    If your app already has users who signed in another way, link an SSO user to an existing account only after a check you trust, such as the email domain being one of the organization’s verified domains.

  5. Scalekit doesn’t return a refresh token: after the callback, your app runs the session for as long as your app decides. Start it the way your app does after any login. When the session ends, send the user through SSO again; if they’re still signed in at their IdP, they come straight back.

    To log a user out, end your app’s session.

    1. Start your app.
    2. Find your test organization’s ID under Organizations in the dashboard of your development environment.
    3. Open http://localhost:3000/auth/sso?organization_id=<test-organization-id>. The SSO simulator opens.
    4. Leave User Login via SSO selected, enter a user name such as jane (the domain is example.com) and a first and last name, and select Simulate Login.

    The callback redirects you to /, which this example doesn’t serve, so a “not found” page is expected. Your users table has a user with the test organization’s ID, jane@example.com and the name you entered. Open http://localhost:3000/auth/callback?code=x&state=forged: the response is 400 Sign-in failed: unexpected state.