---
title: Integrate an external application with ScaiKey (OIDC / OAuth 2.0)
path: tutorials/external-app-oidc
status: published
---

# Integrate an external application with ScaiKey (OIDC / OAuth 2.0)

**This is the canonical integration guide.** ScaiKey is a standards-compliant
OpenID Connect / OAuth 2.0 provider. If your app or its library supports "generic
OIDC" or "OAuth 2.0 / OpenID Connect SSO", it works with ScaiKey.

> **Rule #1 — always start from the discovery document.** Do not hardcode
> endpoint URLs; read them from the well-known document below and let your OIDC
> library configure itself. Endpoint paths and the `issuer` value are stable, but
> discovery is the single source of truth.

Live host: **`https://scaikey.scailabs.ai`** (replace with your ScaiKey host if
self-hosted).

---

## 1. Pick a surface: platform vs tenant

ScaiKey serves two OIDC "front doors". Choose based on whether your app knows the
user's tenant *before* login.

| | **Platform** (recommended default) | **Tenant-scoped** |
|---|---|---|
| Use when | You don't know the user's tenant — resolve it from their email at login (home-realm discovery) | Your app is pinned to exactly one tenant |

Paths below are relative to the live host, `https://scaikey.scailabs.ai`.

- **Platform** (recommended default)
  - Discovery: `/api/v1/platform/.well-known/openid-configuration`
  - `issuer`: `https://scaikey.scailabs.ai/platform`
- **Tenant-scoped**
  - Discovery: `/api/v1/auth/tenants/{slug}/.well-known/openid-configuration`
  - `issuer`: `https://scaikey.scailabs.ai/tenants/{slug}`

**Validate the `iss` of received tokens against the `issuer` from the discovery
document you used** — do not assume the bare host. (Platform tokens carry the
`/platform` suffix; tenant tokens carry `/tenants/{slug}`.)

The endpoints below are shown for the **platform** surface (paths relative to the
host); for the tenant surface, use the URLs from that tenant's discovery document
(they live under `/api/v1/auth/tenants/{slug}/…`).

| Purpose | Platform endpoint (path) |
|---|---|
| authorize | `/api/v1/platform/oauth/authorize` |
| token | `/api/v1/platform/oauth/token` |
| userinfo | `/api/v1/platform/oauth/userinfo` |
| jwks | `/api/v1/platform/.well-known/jwks.json` |
| end session (logout) | `/api/v1/platform/oauth/logout` |
| device authorization | `/api/v1/platform/oauth/device/authorize` |

---

## 2. Register your application

Registration is operator-driven (there is **no** public dynamic-registration
endpoint). Ask the ScaiKey team — or use the admin API / `scaikey` CLI — to
register an application, choosing a client **type**:

| Type | For | Secret? | Auth |
|---|---|---|---|
| `WEB` | Server-side web apps | yes (confidential) | client secret + (recommended) PKCE |
| `SPA` | Browser single-page apps | no (public) | **PKCE required** |
| `NATIVE` | Desktop / mobile | no (public) | **PKCE required**, private-use-scheme redirect |
| `SERVICE` | Machine-to-machine, no user | yes | `client_credentials` |

You provide, and receive back:
- **Provide:** `redirect_uris` (exact-match, incl. path), post-logout URIs (or rely
  on same-origin-as-a-redirect-URI matching), the scopes you need.
- **Receive:** `client_id` (and `client_secret` for `WEB`/`SERVICE`, shown once).

**Scopes** for user login: `openid profile email` (+ `groups` for the group
claim, `offline_access` for a refresh token). For directory read/sync use
`directory:read` — **not** `admin:read` (that's super-admin). `users:read` /
`groups:read` do **not** exist.

---

## 3. Authorization Code + PKCE (the login flow)

Response type is **`code`** only (implicit is not supported). Always use PKCE
(`S256`) — it is **mandatory** for public clients (`SPA`/`NATIVE`) and recommended
for `WEB`.

1. Generate a `code_verifier` and `code_challenge = BASE64URL(SHA256(verifier))`.
2. Redirect the user to the **authorize** endpoint:
   ```
   GET https://scaikey.scailabs.ai/api/v1/platform/oauth/authorize
       ?response_type=code
       &client_id=<your client_id>
       &redirect_uri=<one of your registered redirect_uris>
       &scope=openid%20profile%20email
       &state=<csrf-random>
       &code_challenge=<challenge>
       &code_challenge_method=S256
   ```
3. User authenticates (email → tenant resolved on the platform surface → password/MFA).
   ScaiKey redirects back to your `redirect_uri` with `?code=…&state=…`.
   Verify `state` matches.
4. Exchange the code at the **token** endpoint:
   ```bash
   curl -X POST https://scaikey.scailabs.ai/api/v1/platform/oauth/token \
     -d grant_type=authorization_code \
     -d code=<code> \
     -d redirect_uri=<same redirect_uri as step 2> \
     -d client_id=<your client_id> \
     -d code_verifier=<verifier> \
     # WEB/SERVICE clients also send: -d client_secret=<secret>
     # (public SPA/NATIVE clients send NO secret)
   ```
   → `{ access_token, id_token, refresh_token?, token_type, expires_in, scope }`.

The `redirect_uri` must be **byte-identical** at authorize and token (exact-match,
no normalization).

---

## 4. Validate tokens

- Fetch signing keys from `jwks_uri` (cache them; honour key rotation).
- On the **ID token**, verify: signature (via JWKS), `iss` == the discovery
  `issuer`, `aud` == your `client_id`, `exp`, and `nonce` if you sent one.
- Access tokens are self-contained JWTs (same JWKS). Note they are **not**
  revocation-checked at validation time — a logged-out user's access token stays
  valid until `exp` (default 1 h). Keep access-token lifetime in mind for your
  own session model.

## 4a. GLOBAL applications: one app, many issuers (important)

A **GLOBAL** application (one registration that serves users from many tenants
via the platform realm) does **not** see a single `iss`. This is by design, and a
relying party cannot discover it from the platform discovery document — so it's
stated here explicitly:

- **User tokens are tenant-issued.** After home-realm discovery resolves the user
  to their tenant, the id_token and access_token carry
  `iss = https://scaikey.scailabs.ai/tenants/{slug}` — the user's own tenant — even
  though the flow ran through the platform realm. Each token also carries a
  **`tenant_slug`** claim; verify `iss == …/tenants/{tenant_slug}` to bind the
  issuer to the tenant the token claims.
- **Client-credentials tokens are platform-issued.** A `client_credentials` token
  minted by the same GLOBAL app carries `iss = https://scaikey.scailabs.ai/platform`
  (it has no user, so no tenant).
- **The platform discovery document advertises `issuer = …/platform`.** That value
  matches only the client-credentials tokens, **not** user logins. A stock OIDC RP
  configured solely from platform discovery and pinning a single `iss` will reject
  every user token. That is the trap; the rule below is the way through it.

**The signing keys (JWKS) are global — there is only one key set.** Every realm's
`jwks_uri` (platform and every tenant) returns the *same* keys. So you do **not**
need one JWKS per tenant: fetch one JWKS, verify the **signature** once, then check
the **`iss` string** against an allow-list you maintain:

1. Verify the token signature against the (single, shared) JWKS. Any realm's
   `jwks_uri` works; the platform one is a fine choice.
2. Accept the token only if its `iss` is in your allow-list: the **platform**
   issuer (`…/platform`, for your own client-credentials tokens) **plus** the
   **tenant** issuer (`…/tenants/{slug}`) of each tenant your app serves.
3. For a user token, additionally require `iss == …/tenants/{tenant_slug}` using
   the token's own `tenant_slug` claim.

**Restricting which tenants may log in.** There is currently **no per-tenant
allow-list** on a GLOBAL application in ScaiKey — any user of any tenant can
complete a login unless you enable **`require_assignment`** on the app, which
limits login to explicitly assigned users/groups (assignments themselves may span
tenants). So today the enforcement point for "which tenants" is your RP's `iss`
allow-list (step 2); a first-class per-tenant restriction on the ScaiKey side is a
tracked enhancement, not yet available. If you need it, ask the ScaiKey team.

## 5. UserInfo, refresh, logout

- **UserInfo:** `GET …/oauth/userinfo` with `Authorization: Bearer <access_token>`.
- **Refresh:** `POST …/oauth/token` with `grant_type=refresh_token`,
  `refresh_token=…`, `client_id` (+ secret for confidential). Refresh tokens
  **rotate** on use and enforce reuse-detection (a replayed rotated token revokes
  the whole family). Persist the newest token; don't run concurrent refreshes.
- **RP-initiated logout:** redirect to the `end_session_endpoint` with
  `id_token_hint=<id_token>` and (optionally) `post_logout_redirect_uri`. A
  post-logout URI is accepted if it exactly matches a registered logout URI **or**
  shares scheme+host+port with a registered `redirect_uri`.

### Claims by scope

- **`email`** → `email`, `email_verified`.
- **`profile`** → `name`, `given_name`, `family_name`, `preferred_username`,
  `picture`, `locale`, `zoneinfo`, and — when the user has a manager —
  **`manager`** (the manager's ScaiKey user id, `usr_…`) and
  **`manager_display_name`**. The manager is populated from Active Directory on
  LDAP-synced tenants, or set by an admin on local tenants; both claims are
  omitted when no manager is set. (This is the supported way to render, e.g., a
  manager line in an email signature — read `manager_display_name`.)
- **`groups`** → `groups` (security-group ids), and `distribution_groups` when
  present.

## 6. Machine-to-machine (`SERVICE` / `client_credentials`)

For a service with no user (e.g. directory sync), register a `SERVICE`/`WEB`
GLOBAL app and:
```bash
curl -X POST https://scaikey.scailabs.ai/api/v1/platform/oauth/token \
  -d grant_type=client_credentials \
  -d client_id=<id> -d client_secret=<secret> \
  -d scope=directory:read
```
See also the [service-to-service tutorial](/docs/scaikey/tutorials/service-to-service).

---

## Common pitfalls
- **Wrong base path.** Endpoints live under `/api/v1/platform/…` or
  `/api/v1/auth/tenants/{slug}/…`. There is no `api.scaikey.io` host and no bare
  `/tenants/{slug}/oauth/…` path — read the discovery document.
- **Broken discovery URL.** The discovery document is at
  `…/api/v1/platform/.well-known/openid-configuration` (or the tenant equivalent),
  **not** `/.well-known/openid-configuration` at the root or under `/oauth/`.
- **`iss` mismatch.** Validate against the discovery `issuer` (`…/platform` or
  `…/tenants/{slug}`), not the bare host.
- **Public client sending a secret.** `SPA`/`NATIVE` clients send no secret; PKCE
  is the proof.
- **Redirect URI not exact-match.** Register every callback exactly, including path.
