OAuth and OIDC
ScaiKey is a standard OpenID Connect provider. If your client library handles OIDC correctly, you mostly need to know two things: which discovery URL to point at, and which grant type fits your use case.
Discovery URLs#
| Use case | Discovery URL |
|---|---|
GLOBAL-scoped app authenticating users across tenants |
$SCAIKEY/api/v1/platform/oauth/.well-known/openid-configuration |
TENANT-scoped app authenticating a single tenant's users |
$SCAIKEY/api/v1/auth/tenants/{slug}/.well-known/openid-configuration |
Point your OIDC library at the URL that matches your app's scope. The library reads it once at startup; from then on it knows every endpoint, scope, and signing key URL it needs.
Supported grants#
authorization_code — interactive user login#
The standard browser-based flow. User clicks "Log in with ScaiKey", gets redirected to ScaiKey's hosted login page, authenticates (with MFA if required by the tenant), is redirected back to your app with a code, and your backend swaps the code for tokens.
Required for SPAs and native apps: PKCE. Public clients (SPA, NATIVE application types) must use code_challenge + code_verifier. Confidential clients (WEB, SERVICE) can use PKCE too — recommended.
client_credentials — service-to-service, no user#
Your backend exchanges its client_id + client_secret for an access token. There's no user, no sub claim from a person — sub is set to the client_id. Use this for backend integrations, scheduled jobs, internal tools.
Only WEB and SERVICE application types can use this grant (they're confidential clients with a secret). SPA and NATIVE cannot.
refresh_token — extend a session without re-prompting#
Standard refresh token grant. ScaiKey rotates refresh tokens on every use (one-time-use refresh tokens). The previous refresh token is invalidated atomically when the new one is issued.
Refresh tokens are issued only when:
- Your app is
WEBorNATIVE(typically need long-lived sessions), or - The original authorization request included the
offline_accessscope.
urn:ietf:params:oauth:grant-type:token-exchange — RFC 8693#
Exchange a token issued by one ScaiKey-registered application for a token audience-restricted to another. Common pattern: a frontend gets a user token for "MyApp", which needs to call "BackendService" — MyApp's server exchanges the user token for a BackendService-audience token before calling.
Constraints:
- Subject token must be a ScaiKey-issued access token (we don't accept foreign tokens).
- The target application must opt in (
token_exchange_allowed = trueon its row). - Scopes can only narrow, never widen: granted =
subject_scopes ∩ target.allowed_scopes ∩ requested. - Self-exchange is rejected.
- The exchanged token's
expires_inis the target'stoken_lifetime, not the subject token's remaining lifetime — exchange effectively "refreshes" the wall clock for the new audience. - The new token carries an
actclaim recording who performed the exchange (delegation chain is preserved if subject already had anact).
See Reference → OAuth endpoints → Token endpoint for the exact form parameters.
urn:ietf:params:oauth:grant-type:device_code — RFC 8628#
The "TV login" flow. Your device shows a short user code, the user types it in on a separate device's browser to approve, your device polls until approval and gets tokens. Use this for CLI tools, IoT devices, headless setups.
When to use which#
| Situation | Grant |
|---|---|
| Web app with a backend, users log in via browser | authorization_code |
| Single-page app (no backend) | authorization_code + PKCE |
| Mobile / native desktop app | authorization_code + PKCE |
| Backend service calling another backend service | client_credentials |
| Backend needs a token audience-restricted to a different downstream | Token Exchange |
| CLI or headless device | device_code |
| You already have a session and just need a fresh access token | refresh_token |
What we don't support#
passwordgrant (Resource Owner Password Credentials, ROPC). Deprecated and dangerous. Don't ask. Useauthorization_codeand direct users to the hosted login page.implicitgrant. Deprecated in OAuth 2.1. Useauthorization_code+ PKCE instead.
Scopes#
openid is required for OIDC flows. profile, email, groups are the standard claims-bearing scopes. offline_access requests a refresh token. ScaiKey-specific admin scopes (admin:read, admin:write, applications:write) gate access to its own management API — see Concepts → Tokens and scopes.
A previous version of this page listed
users:read/groups:readas separate scopes. They do not exist — the:read/:writesplit applies toadmin:*only. Thegroupsscope (no suffix) controls whether thegroupsclaim is emitted on user-shaped tokens.
The groups claim#
When the groups scope is requested, ScaiKey emits a groups claim:
1 | |
Two properties make this claim safe to authorize from on the hot path:
- Stable IDs, not names. Entries are the
grp_-prefixed primary keys. Renaming a group does not change what the token says. Display names should be resolved through the directory API at presentation time. - Transitively expanded. Direct memberships plus every ancestor group via nested-group containment, walked cycle-safely.
Soft-deleted groups are filtered. The claim is omitted entirely if the user has no group memberships. Re-evaluated on every token mint, including refresh-token exchange. For tighter invalidation, subscribe to the group.member_added / group.member_removed webhook events.
The claim includes both SECURITY and DISTRIBUTION groups. Filter on the consumer side if you only want one type.
Partner-scoped tokens#
Service-to-service tokens (the client_credentials grant) can carry a partner_ids claim listing which partners the token is allowed to act on. Downstream resource servers use this claim to refuse cross-partner requests at the request boundary.
The claim is only emitted when the calling application is partner-scoped:
| Application scope | partner_ids claim |
|---|---|
TENANT (default) |
omitted — token has no partner restriction |
PARTNER, one partner |
["prt_partner"] |
PARTNER, multi-partner |
["prt_a", "prt_b", "prt_c"] (deduped, sorted) |
GLOBAL (operator-only) |
omitted — token is platform-wide |
The shape is always an array, even for single-partner tokens, so consumers can handle one and many uniformly. When the claim is absent, the token has no partner restriction — treat it the same as the legacy unscoped behavior. Multi-partner is configured on the application itself (partner_id + allowed_partner_ids); only super-admins can grant additional partners.
The created_at claim#
User-shaped tokens (authorization_code, refresh_token, and federation flows) carry a created_at claim — Unix timestamp of when the user account was first created.
1 | |
Used by registration-flow consumers to distinguish a freshly-registered user from a returning user without an admin-API round-trip. now - created_at < ~5 min is a near-zero-false-positive "just signed up" check. Stable across the token's lifetime; the same claim value appears on every token a user ever mints, so it's safe to use as a registration-tracking signal exactly once at first sight.
Authorize-endpoint parameters (extensions)#
In addition to the standard OIDC parameters, ScaiKey honours:
idp_hint=<idp_id>— skip the IdP picker and route straight to a federated login. See Federation.login_hint=<email>— pre-fill the email field on the login page. Standard OIDC.login_hint_required=true— ScaiKey-specific extension. When set alongsidelogin_hint, the email field is rendered disabled so a user can't substitute a different address by typing into the form. This is a UX assist, not a security boundary. ScaiKey does not currently enforce the pin server-side at the credential-verify step — a client with JavaScript disabled, or a hand-crafted POST to/auth/login, can still authenticate with a different email. Callers usinglogin_hintto bind an invitation flow MUST validate the final identity in their callback (e.g. comparing the ID token'semailclaim against the pinned address) and reject mismatches there. We may add server-side enforcement in a future release; this paragraph will be updated when it lands.