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
statevalue 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
Before you start
Section titled “Before you start”-
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
.envfile.REDIRECT_URIis your registered callback URL.SESSION_SECRETsigns 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/callbackSESSION_SECRET=<a-long-random-value>
Build the endpoints
Section titled “Build the endpoints”-
Install the SDK and a web framework
Section titled “Install the SDK and a web framework”Terminal window npm install @scalekit-sdk/node express express-session dotenvTerminal window pip install scalekit-sdk-python flask python-dotenvTerminal window go get -u github.com/scalekit-inc/scalekit-sdk-go/v2Add
com.scalekit:scalekit-sdk-javato a Spring Boot web app. See Install the SDK for the Maven and Gradle lines. -
Add the login and callback endpoints
Section titled “Add the login and callback endpoints”The example has two endpoints:
/auth/ssostarts a login. It creates a randomstatevalue, 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/callbackfinishes 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 IdPObject.assign(user, { email, name });return user;}// Start an SSO login for one of your customersapp.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 organizationclaims = 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 reusedreq.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.app.py import osimport secretsfrom dotenv import load_dotenvfrom flask import Flask, redirect, request, sessionfrom scalekit import AuthorizationUrlOptions, CodeAuthenticationOptions, ScalekitClientload_dotenv()scalekit_client = ScalekitClient(env_url=os.environ["SCALEKIT_ENVIRONMENT_URL"],client_id=os.environ["SCALEKIT_CLIENT_ID"],client_secret=os.environ["SCALEKIT_CLIENT_SECRET"],)redirect_uri = os.environ["REDIRECT_URI"]app = Flask(__name__)app.secret_key = os.environ["SESSION_SECRET"]# Your users table. Replace with your database.users = {}def find_or_create_user(organization_id, idp_user_id, email, name):key = (organization_id, idp_user_id)user = users.get(key)if user is None:user = {"id": secrets.token_hex(16), "organization_id": organization_id, "idp_user_id": idp_user_id}users[key] = user# Keep the profile current: email and name can change at the IdPuser.update(email=email, name=name)return user# Start an SSO login for one of your customers@app.route("/auth/sso")def start_sso():state = secrets.token_urlsafe(16)session["sso_state"] = stateoptions = AuthorizationUrlOptions()options.organization_id = request.args.get("organization_id")options.state = statereturn redirect(scalekit_client.get_authorization_url(redirect_uri=redirect_uri, options=options))@app.route("/auth/callback")def callback():expected_state = session.pop("sso_state", None)error = request.args.get("error")if error:app.logger.warning("SSO login failed: %s: %s", error, request.args.get("error_description"))return "Sign-in failed", 401state = request.args.get("state")if not state or state != expected_state:return "Sign-in failed: unexpected state", 400try:result = scalekit_client.authenticate_with_code(request.args.get("code"), redirect_uri, CodeAuthenticationOptions())except Exception as e:app.logger.warning("Code exchange failed: %s", e)return "Sign-in failed", 401profile = result["user"]user = find_or_create_user(# From the ID token's oid claim: the user's organizationorganization_id=result["organization_id"],idp_user_id=profile["id"].split(";", 1)[1],email=profile.get("email"),name=" ".join(filter(None, [profile.get("givenName"), profile.get("familyName")])),)# Start a fresh session for the usersession.clear()session["user_id"] = user["id"]return redirect("/")if __name__ == "__main__":app.run(port=3000)Run it with
python app.py.main.go package mainimport ("crypto/rand""encoding/hex""log""net/http""os""strings""sync"scalekit "github.com/scalekit-inc/scalekit-sdk-go/v2")var (scalekitClient = scalekit.NewScalekitClient(os.Getenv("SCALEKIT_ENVIRONMENT_URL"),os.Getenv("SCALEKIT_CLIENT_ID"),os.Getenv("SCALEKIT_CLIENT_SECRET"),)redirectURI = os.Getenv("REDIRECT_URI")// Your users table. Replace with your database.usersMu sync.Mutexusers = map[string]*AppUser{})type AppUser struct {ID, OrganizationID, IdpUserID, Email, Name string}func findOrCreateUser(organizationID, idpUserID, email, name string) *AppUser {usersMu.Lock()defer usersMu.Unlock()key := organizationID + ":" + idpUserIDuser, ok := users[key]if !ok {user = &AppUser{ID: randomHex(), OrganizationID: organizationID, IdpUserID: idpUserID}users[key] = user}// Keep the profile current: email and name can change at the IdPuser.Email, user.Name = email, namereturn user}func randomHex() string {b := make([]byte, 16)if _, err := rand.Read(b); err != nil {panic(err)}return hex.EncodeToString(b)}// Start an SSO login for one of your customersfunc startSSO(w http.ResponseWriter, r *http.Request) {state := randomHex()http.SetCookie(w, &http.Cookie{Name: "sso_state", Value: state, Path: "/auth", MaxAge: 600,HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode,})authURL, err := scalekitClient.GetAuthorizationUrl(redirectURI, scalekit.AuthorizationUrlOptions{OrganizationId: r.URL.Query().Get("organization_id"),State: state,})if err != nil {http.Error(w, "Could not start sign-in", http.StatusInternalServerError)return}http.Redirect(w, r, authURL.String(), http.StatusFound)}func callback(w http.ResponseWriter, r *http.Request) {q := r.URL.Query()stateCookie, cookieErr := r.Cookie("sso_state")http.SetCookie(w, &http.Cookie{Name: "sso_state", Path: "/auth", MaxAge: -1})if errCode := q.Get("error"); errCode != "" {log.Printf("SSO login failed: %s: %s", errCode, q.Get("error_description"))http.Error(w, "Sign-in failed", http.StatusUnauthorized)return}if cookieErr != nil || q.Get("state") == "" || q.Get("state") != stateCookie.Value {http.Error(w, "Sign-in failed: unexpected state", http.StatusBadRequest)return}result, err := scalekitClient.AuthenticateWithCode(r.Context(), q.Get("code"), redirectURI, scalekit.AuthenticationOptions{},)if err != nil {log.Printf("Code exchange failed: %v", err)http.Error(w, "Sign-in failed", http.StatusUnauthorized)return}// The ID token's oid claim is the user's organizationclaims, err := scalekitClient.ValidateToken(r.Context(), result.IdToken)organizationID, _ := claims["oid"].(string)if err != nil || organizationID == "" {log.Printf("ID token check failed: %v", err)http.Error(w, "Sign-in failed", http.StatusUnauthorized)return}profile := result.User_, idpUserID, _ := strings.Cut(profile.Id, ";")name := strings.TrimSpace(profile.GivenName + " " + profile.FamilyName)user := findOrCreateUser(organizationID, idpUserID, profile.Email, name)// Start your app's session for user.ID here, the way your app does after any loginlog.Printf("Signed in user %s", user.ID)http.Redirect(w, r, "/", http.StatusFound)}func main() {http.HandleFunc("/auth/sso", startSSO)http.HandleFunc("/auth/callback", callback)log.Fatal(http.ListenAndServe(":3000", nil))}Go doesn’t load
.envfiles on its own: export the variables in your shell, then rungo run main.go.SsoController.java package com.example.sso;import com.scalekit.ScalekitClient;import com.scalekit.internal.http.AuthenticationOptions;import com.scalekit.internal.http.AuthenticationResponse;import com.scalekit.internal.http.AuthorizationUrlOptions;import com.scalekit.internal.http.IdTokenClaims;import jakarta.servlet.http.HttpServletRequest;import jakarta.servlet.http.HttpSession;import java.security.SecureRandom;import java.util.HexFormat;import java.util.Map;import java.util.UUID;import java.util.concurrent.ConcurrentHashMap;import org.slf4j.Logger;import org.slf4j.LoggerFactory;import org.springframework.http.ResponseEntity;import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.RequestParam;import org.springframework.web.bind.annotation.RestController;@RestControllerpublic class SsoController {private static final Logger log = LoggerFactory.getLogger(SsoController.class);private final ScalekitClient scalekitClient = new ScalekitClient(System.getenv("SCALEKIT_ENVIRONMENT_URL"),System.getenv("SCALEKIT_CLIENT_ID"),System.getenv("SCALEKIT_CLIENT_SECRET"));private final String redirectUri = System.getenv("REDIRECT_URI");private final SecureRandom random = new SecureRandom();// Your users table. Replace with your database.public record AppUser(String id, String organizationId, String idpUserId, String email, String name) {}private final Map<String, AppUser> users = new ConcurrentHashMap<>();private AppUser findOrCreateUser(String organizationId, String idpUserId, String email, String name) {// Keep the profile current: email and name can change at the IdPreturn users.compute(organizationId + ":" + idpUserId, (key, existing) -> new AppUser(existing != null ? existing.id() : UUID.randomUUID().toString(),organizationId, idpUserId, email, name));}// Start an SSO login for one of your customers@GetMapping("/auth/sso")public ResponseEntity<Void> startSso(@RequestParam("organization_id") String organizationId, HttpSession session) {byte[] bytes = new byte[16];random.nextBytes(bytes);String state = HexFormat.of().formatHex(bytes);session.setAttribute("ssoState", state);AuthorizationUrlOptions options = new AuthorizationUrlOptions();options.setOrganizationId(organizationId);options.setState(state);String url = scalekitClient.authentication().getAuthorizationUrl(redirectUri, options).toString();return ResponseEntity.status(302).header("Location", url).build();}@GetMapping("/auth/callback")public ResponseEntity<String> callback(@RequestParam Map<String, String> params, HttpServletRequest request) {HttpSession session = request.getSession();String expectedState = (String) session.getAttribute("ssoState");session.removeAttribute("ssoState");String error = params.get("error");if (error != null) {log.warn("SSO login failed: {}: {}", error, params.get("error_description"));return ResponseEntity.status(401).body("Sign-in failed");}String state = params.get("state");if (state == null || !state.equals(expectedState)) {return ResponseEntity.status(400).body("Sign-in failed: unexpected state");}AuthenticationResponse result;Map<String, Object> claims;try {result = scalekitClient.authentication().authenticateWithCode(params.get("code"), redirectUri, new AuthenticationOptions());// Validates the ID token's signature; its oid claim is the user's organizationclaims = scalekitClient.authentication().validateAccessTokenAndGetClaims(result.getIdToken());} catch (Exception e) {log.warn("Code exchange failed: {}", e.getMessage());return ResponseEntity.status(401).body("Sign-in failed");}IdTokenClaims profile = result.getIdTokenClaims();String sub = profile.getId();String name = String.join(" ",java.util.stream.Stream.of(profile.getGivenName(), profile.getFamilyName()).filter(n -> n != null && !n.isBlank()).toList());AppUser user = findOrCreateUser((String) claims.get("oid"),sub.substring(sub.indexOf(';') + 1),profile.getEmail(),name);// Start a fresh session for the user, so a session ID set before login can't be reusedsession.invalidate();request.getSession(true).setAttribute("userId", user.id());return ResponseEntity.status(302).header("Location", "/").build();}} -
Check the response before you trust it
Section titled “Check the response before you trust it”The callback handles three outcomes, in this order:
- An
errorparameter. The login failed at Scalekit or at the identity provider (IdP). Logerroranderror_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
statethat 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.
- An
-
Find or create the user
Section titled “Find or create the user”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’ssub) 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.
-
Start your session
Section titled “Start your session”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.
-
Check it worked
Section titled “Check it worked”- Start your app.
- Find your test organization’s ID under Organizations in the dashboard of your development environment.
- Open
http://localhost:3000/auth/sso?organization_id=<test-organization-id>. The SSO simulator opens. - Leave User Login via SSO selected, enter a user name such as
jane(the domain isexample.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.comand the name you entered. Openhttp://localhost:3000/auth/callback?code=x&state=forged: the response is400 Sign-in failed: unexpected state.
- Onboard an enterprise customer: create their organization and give their IT admin the admin portal.
- Map custom user attributes: send roles or groups from the IdP and use them in your app.