Platform
ScaiWave ScaiGrid ScaiCore ScaiBot ScaiDrive ScaiKey Modellen Tools & Services
Oplossingen
Organisaties Ontwikkelaars Internet Service Providers Managed Service Providers AI-in-a-Box
Kenniscentrum
Ondersteuning Documentation Blog Downloads
Bedrijf
Over ons Onderzoek Vacatures Investeren Contact
Inloggen

Login and logout for web applications

The complete lifecycle for an external web application: from the first redirect to ScaiKey, through token handling, to a logout that actually logs the user out.

Read this end to end before integrating. Most integration problems we see come from one of two places — the token exchange, or treating logout as a client-side concern.

The mental model: there are two sessions#

This is the single most important idea on the page.

Session Owned by Ended by
Your application session You (cookie, server session, JWT in storage) Your logout handler
The ScaiKey SSO session ScaiKey (an HttpOnly cookie on scaikey.scailabs.ai) Only by calling our end-session endpoint

If you clear only your own session and redirect to your login page, the browser still holds the ScaiKey SSO cookie. Your login redirect hits /oauth/authorize, ScaiKey sees a valid session, and issues a fresh authorization code without prompting. The user is back where they started, apparently never logged out.

A logout that does not call ScaiKey's end-session endpoint is not a logout.

Before you start#

Your application must be registered with ScaiKey. Ask the platform team for:

Field What it is
client_id Public identifier for your application.
client_secret Confidential clients only. Never ship this to a browser.
redirect_uris Exact URLs we will return authorization codes to. Exact-matched.
logout_uris Optional. Where we may send users after logout. See Post-logout redirects.
Scope GLOBAL (usable across tenants) or tenant-scoped. Decides which endpoints you use.

Which endpoint family do you use?#

Your application is Use Discovery document
GLOBAL-scoped /api/v1/platform/oauth/* $SCAIKEY/api/v1/platform/oauth/.well-known/openid-configuration
Tenant-scoped /api/v1/auth/tenants/{slug}/oauth/* $SCAIKEY/api/v1/auth/tenants/{slug}/.well-known/openid-configuration

Pair them consistently: if you authorize against the platform endpoint, log out against the platform endpoint. Mixing families works but makes debugging harder.

Throughout this page, $SCAIKEY is https://scaikey.scailabs.ai.

Always read endpoints from discovery#

bash
1
curl -s "$SCAIKEY/api/v1/platform/oauth/.well-known/openid-configuration" | jq
json
1
2
3
4
5
6
7
8
{
  "issuer": "https://scaikey.scailabs.ai/platform",
  "authorization_endpoint": "https://scaikey.scailabs.ai/api/v1/platform/oauth/authorize",
  "token_endpoint": "https://scaikey.scailabs.ai/api/v1/platform/oauth/token",
  "userinfo_endpoint": "https://scaikey.scailabs.ai/api/v1/platform/oauth/userinfo",
  "end_session_endpoint": "https://scaikey.scailabs.ai/api/v1/platform/oauth/logout",
  "jwks_uri": "https://scaikey.scailabs.ai/api/v1/platform/.well-known/jwks.json"
}

Note that issuer and the endpoint URLs have different base paths. That is deliberate: issuer must equal the iss claim we mint into tokens, while the endpoints live under the API prefix. Validate iss against issuer; send requests to the endpoint URLs. Do not construct endpoint URLs by appending to issuer.


Part 1 — Login#

Step 1: Generate PKCE values#

Required for public clients (SPAs, mobile), recommended for everyone.

python
1
2
3
4
5
6
import base64, hashlib, secrets

verifier  = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode()
challenge = base64.urlsafe_b64encode(
    hashlib.sha256(verifier.encode()).digest()
).rstrip(b"=").decode()

Store verifier in the user's pre-login session. You need it at Step 4.

Step 2: Redirect to the authorize endpoint#

carbon
1
2
3
4
5
6
7
8
GET $SCAIKEY/api/v1/platform/oauth/authorize
  ?response_type=code
  &client_id=<your client_id>
  &redirect_uri=https%3A%2F%2Fyourapp.example%2Fauth%2Fcallback
  &scope=openid%20profile%20email
  &state=<random, stored in session>
  &code_challenge=<challenge>
  &code_challenge_method=S256
Parameter Required Notes
response_type yes Always code.
client_id yes
redirect_uri yes Must exactly match a registered value.
scope yes Include openid to receive an ID token. Add offline_access for a refresh token.
state yes Opaque CSRF value. Verify it on the callback.
code_challenge public clients Base64url SHA-256 of the verifier.
nonce recommended Echoed into the ID token; bind it to the user's session.
prompt=login optional Forces re-authentication even if an SSO session exists.
login_hint optional Pre-fills the username field.

If the user already has a valid SSO session, ScaiKey returns a code immediately without showing a login screen. That is single sign-on working as intended.

Step 3: Handle the callback#

We redirect to your redirect_uri with code and state.

Verify state matches the value you stored. If it does not, abort — do not exchange the code.

Step 4: Exchange the code for tokens#

Server-side, within about 60 seconds. Codes are single-use.

bash
1
2
3
4
5
6
7
8
curl -X POST "$SCAIKEY/api/v1/platform/oauth/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=authorization_code" \
  -d "code=$CODE" \
  -d "redirect_uri=https://yourapp.example/auth/callback" \
  -d "client_id=$CLIENT_ID" \
  -d "client_secret=$CLIENT_SECRET" \
  -d "code_verifier=$VERIFIER"
json
1
2
3
4
5
6
7
8
{
  "access_token": "eyJhbGciOiJSUzI1NiIs...",
  "id_token": "eyJhbGciOiJSUzI1NiIs...",
  "refresh_token": "3f9a…",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "openid profile email"
}

redirect_uri must be identical to the one from Step 2 — it is verified, not decorative.

Step 5: Validate the ID token#

Before trusting any claim:

  1. Verify the signature against jwks_uri (cache the keys; honour kid).
  2. Check iss equals the issuer from discovery — exactly, no normalisation.
  3. Check aud contains your client_id.
  4. Check exp is in the future.
  5. If you sent nonce, check it matches.

Claims you can expect:

Claim Meaning
sub Stable user ID. Use this as your foreign key, never the email.
sid The SSO session this login belongs to. Keep it — see Part 3.
tenant_id The user's tenant.
email, name, given_name, family_name Identity claims, subject to scope.
groups Group memberships, if the groups scope was granted.
auth_time When the user actually authenticated.

Step 6: Establish your own session — and store the ID token#

Create your application session now. Persist the raw id_token alongside it. You need it at logout as id_token_hint, and it is the most common thing teams forget.

python
1
2
3
session["user_id"]  = claims["sub"]
session["email"]    = claims["email"]
session["id_token"] = tokens["id_token"]   # required for a correct logout

Part 2 — Using and refreshing tokens#

Send the access token to resource servers:

scdoc
1
Authorization: Bearer <access_token>

When it expires, use the refresh token:

bash
1
2
3
4
5
6
curl -X POST "$SCAIKEY/api/v1/platform/oauth/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=refresh_token" \
  -d "refresh_token=$REFRESH_TOKEN" \
  -d "client_id=$CLIENT_ID" \
  -d "client_secret=$CLIENT_SECRET"

Refresh tokens rotate: each refresh returns a new one and retires the old. Store the new value immediately.

A refresh token is bound to the SSO session that created it. When that session ends — because the user logged out, an administrator revoked it, or it expired — the refresh token is revoked with it and further refreshes fail with invalid_grant. Treat that as "the user must log in again", not as an error to retry.


Part 3 — Logout#

The complete sequence#

  1. Clear your own application session.
  2. Redirect the browser to the end-session endpoint, with id_token_hint.
  3. We terminate the SSO session, revoke refresh tokens, and clear our cookie.
  4. We redirect the browser back to your post_logout_redirect_uri.
scdoc
1
2
3
4
GET $SCAIKEY/api/v1/platform/oauth/logout
  ?id_token_hint=<the id_token you stored at Step 6>
  &post_logout_redirect_uri=https%3A%2F%2Fyourapp.example%2Flogin%3Flogged_out%3D1
  &state=<optional>
Parameter Required Notes
id_token_hint strongly recommended Identifies the session (sid) and client (aud). Send it even if expired — we verify the signature, not the expiry.
post_logout_redirect_uri optional Must pass validation below, or it is ignored.
state optional Echoed back on the redirect.
client_id optional Fallback identification when you cannot send id_token_hint.

Make sure you have the right endpoint#

ScaiKey has several routes with "logout" in the path. Only two are OIDC end-session endpoints, and only those accept a browser redirect:

Route Method Purpose
/api/v1/platform/oauth/logout GET OIDC end-session — GLOBAL apps. Use this.
/api/v1/auth/tenants/{slug}/oauth/logout GET OIDC end-session — tenant-scoped apps. Use this.
/api/v1/auth/logout POST Portal session API. Not an end-session endpoint.
/api/v1/auth/tenants/{slug}/logout POST Portal session API. Not an end-session endpoint.
/api/v1/admin/auth/logout POST Admin console. Not for integrating applications.
/api/v1/auth/tenants/{slug}/backchannel-logout POST Back-channel logout we call on you.

The distinguishing feature is the oauth/ segment. If you send a browser to one of the POST routes you will get 405 Method Not Allowed with Allow: POST, and — because the navigation fails quietly — a logout that appears to do nothing.

Take the URL from end_session_endpoint in the discovery document and this cannot happen.

Two rules people get wrong#

Redirect the browser. Do not call this from your server. The endpoint identifies the session from the browser's SSO cookie. A back-channel HTTP call from your backend carries no cookie, ends nothing, and returns a redirect your server then discards. The user stays signed in.

Clearing your session is not enough on its own. See The mental model at the top.

What logout does and does not do#

Effect Behaviour
ScaiKey SSO session Terminated. Next authorize forces a fresh login.
SSO cookie Cleared.
Refresh tokens Revoked — those bound to the session, plus those of the client identified by id_token_hint.
Access tokens Not revoked. They are self-contained JWTs and remain valid until exp.
Your application session Your responsibility.

Because access tokens survive, keep their lifetime short if logout needs to take effect quickly, and use introspection at resource servers when you need certainty:

bash
1
2
curl -X POST "$SCAIKEY/api/v1/auth/tenants/{slug}/oauth/introspect" \
  -u "$CLIENT_ID:$CLIENT_SECRET" -d "token=$ACCESS_TOKEN"

Post-logout redirects#

post_logout_redirect_uri is validated. An unaccepted value is ignored and the user lands on ScaiKey's generic signed-out page instead of returning to your application.

A value is accepted when it either:

  • exactly matches one of your registered logout_uris, or
  • shares an origin (scheme + host + port) with one of your registered redirect_uris.

Matching is exact. https://app.example.com does not authorise:

Rejected Why
http://app.example.com/... scheme differs
https://app.example.com.attacker.net/... host differs
https://app.example.com:8443/... port differs

If you send id_token_hint or client_id, we check against that application alone. If you send neither, we check against every active application in scope — the redirect still works, but we cannot revoke your client's tokens by client. Send id_token_hint.

Register an explicit logout_uri only when your signed-out page lives on a different origin than your OAuth callback.

Worked example (Flask)#

python
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
from flask import Flask, redirect, session, request
from urllib.parse import urlencode

SCAIKEY = "https://scaikey.scailabs.ai/api/v1/platform"

@app.route("/auth/callback")
def callback():
    tokens = exchange_code(request.args["code"])      # Steps 3-4
    claims = validate_id_token(tokens["id_token"])    # Step 5
    session["user_id"]  = claims["sub"]
    session["id_token"] = tokens["id_token"]          # keep it for logout
    return redirect("/dashboard")

@app.route("/auth/logout")
def logout():
    id_token = session.get("id_token")
    session.clear()                                   # 1. end your session
    params = urlencode({                              # 2. end ours
        "id_token_hint": id_token or "",
        "post_logout_redirect_uri": "https://yourapp.example/login?logged_out=1",
    })
    return redirect(f"{SCAIKEY}/oauth/logout?{params}")

Federated logins#

When a user signs in through an external identity provider (OIDC or SAML), ScaiKey still creates its own SSO session, and everything above applies unchanged.

One caveat: logging out of ScaiKey does not log the user out of the upstream IdP. If that IdP still holds a session, clicking its button again may sign the user straight back in without a prompt. When you need both ended, call the IdP's own end-session endpoint after ours.

Integration checklist#

  • Endpoints read from the discovery document, not hardcoded
  • iss validated against issuer from discovery, aud against your client_id
  • state verified on the callback
  • PKCE used (mandatory for public clients)
  • sub used as the user key, not email
  • id_token stored at login for use at logout
  • Logout clears your session and redirects to the end-session endpoint
  • Logout is a browser redirect, not a server-side call
  • post_logout_redirect_uri on a registered origin
  • Rotated refresh tokens persisted on every refresh
  • invalid_grant on refresh handled as "log in again"

Troubleshooting#

Symptom Cause
Logout returns the user, still signed in You are not calling the end-session endpoint, or you are calling it server-side.
Logout redirects to ScaiKey's signed-out page, not your app post_logout_redirect_uri failed validation. Check scheme, host and port against your registered URIs.
404 on a logout or token URL The URL is missing the /api/v1/... prefix. Read it from discovery.
405 Method Not Allowed, Allow: POST on logout You are on a portal-session route, not the OIDC end-session endpoint. The end-session paths contain oauth/.
404 on /api/v1/platform/.well-known/openid-configuration Fixed 2026-08-22 — both this path and the older /api/v1/platform/oauth/.well-known/... now serve the same document.
invalid_grant on refresh The SSO session ended, or the refresh token was already rotated. Send the user through login.
invalid_grant on the code exchange Code expired, already used, or redirect_uri/code_verifier mismatch.
ID token rejected by your library iss compared against the endpoint base rather than the issuer from discovery.
User is signed straight back in after logging out The SSO session survived — the end-session call did not happen or did not carry the cookie.
Updated 2026-08-22 12:11:47 View source (.md) rev 2