---
audience: integrating teams
summary: "Complete OAuth/OIDC lifecycle for external web apps \u2014 authorize, token\
  \ exchange, refresh, and a logout that actually ends the ScaiKey session."
title: Login and logout for web applications
path: tutorials/web-app-login-and-logout
status: published
---

# 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
curl -s "$SCAIKEY/api/v1/platform/oauth/.well-known/openid-configuration" | jq
```

```json
{
  "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
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

```
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
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
{
  "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
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:

```
Authorization: Bearer <access_token>
```

When it expires, use the refresh token:

```bash
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`.

```
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
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
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. |
