Skip to content

Authentication & OIDC

All protocol endpoints live under the single versioned prefix /auth/v1 (the bare prefix and unknown paths redirect permanently to the entry path). The default tenant’s issuer derives from configuration as <scheme>://<public_url>/auth/v1/. An additional tenant uses its own tenant-prefixed issuer, or its own hostname (see Tenant hostnames). When [federation] enabled = true, each registered OIDC provider exposes an upstream callback at /auth/v1/{tenant}/providers/{provider}/callback (omit {tenant} for the default tenant). Copy the exact URI from the provider editor; see Federation for current capabilities and limitations.

The discovery document is served where OpenID Connect clients look for it, at the issuer’s own address (<issuer>/.well-known/openid-configuration), and at the host root:

Path
/auth/v1/.well-known/openid-configuration
/auth/v1/.well-known/oauth-authorization-server
/.well-known/openid-configuration
/.well-known/oauth-authorization-server
/.well-known/oauth-authorization-server/auth/v1
/.well-known/oauth-authorization-server/auth/v1/

A tenant served on its own path has its document at /auth/v1/<tenant>/.well-known/openid-configuration.

With [fedcm] enabled = true, the browser-facing web-identity discovery document is additionally served at /.well-known/web-identity.

Flow Entry points
Authorization code + PKCE GET /auth/v1/oidc/authorize → login page → POST credential submission → code → POST /auth/v1/oidc/token
Silent re-authentication POST /auth/v1/oidc/authorize/refresh
Client credentials POST /auth/v1/oidc/token (client authentication)
Refresh POST /auth/v1/oidc/token (refresh token; device-bound sets for the device grant)
Device (RFC 8628) POST /auth/v1/oidc/device → verification page → POST /auth/v1/oidc/device/verify → poll token

The console’s own sign-in uses the authorization-code flow with PKCE (S256), a nonce, a fixed minimal scope set, and the fixed admin-console client identity — the same machinery any relying party uses.

POST /auth/v1/oidc/device starts the grant and returns a device code plus the user code shown to the human operator; the bundled verification page and the decision endpoint (POST /auth/v1/oidc/device/verify) complete it. The token endpoint is polled at the advertised poll_interval_secs (default 5). Device refresh tokens are device-bound with a dedicated lifetime (refresh_lifetime_hours, default 72 h); a user’s devices can be listed, renamed, and their refresh capability revoked through /auth/v1/users/{id}/devices.

Tuning lives in the [device_grant] section (code lifetime 300 s, user-code length 8, optional per-address start rate limit).

Every deployment holds one signing key per algorithm and publishes all of them at /auth/v1/oidc/certs (the jwks_uri), so a client can choose the algorithm its libraries handle best:

Algorithm Key type Notes
RS256 RSA 2048 The default. Every OpenID Connect library supports it.
ES256 EC P-256 Smaller tokens and keys. Signatures are the fixed-width r || s form JWS requires, not DER.
EdDSA OKP Ed25519 Compact and fast, but not every library verifies it.
RS384, RS512 RSA 3072, 4096 For policies that ask for a longer digest.

A client names its algorithms with access_token_signed_response_alg and id_token_signed_response_alg (the console offers the same choices); one that names none gets RS256. A token carries its algorithm and key id in the header, and the key it names is in the published set. The first key of the set is always the RS256 one, so a consumer that reads only the first key still works. The platform standard asks every IdP to sign with RS256 or ES256 and every relying party to accept both; this provider does both, and also verifies ES256 ID tokens from upstream providers and ES256 DPoP proofs.

Endpoint Method Purpose
/auth/v1/oidc/token POST Token issuance (all grants).
/auth/v1/oidc/introspect POST Token introspection.
/auth/v1/oidc/token/revoke POST Token revocation.
/auth/v1/oidc/userinfo GET/POST Userinfo document.
/auth/v1/oidc/forward_auth GET/POST Forward-authentication headers.
/auth/v1/oidc/certs GET Published key set (JWKS).
/auth/v1/oidc/certs/{kid} GET Published key by id.
/auth/v1/oidc/rotate_jwk POST Rotate the signing keys (admin).

Signing keys rotate on the [lifetimes] jwk_autorotate_cron schedule (default 0 30 3 1 * * *, first day of each month) or manually through the rotation endpoint; the rotation emits a JwksRotated event at the configured level_jwks_rotate severity.

GET /auth/v1/oidc/authorize validates the client, resolves steering, creates a preliminary session, and renders the login page with the session cookie set. Credential submission (POST on the same path) is gated by a proof-of-work challenge fetched from POST /auth/v1/pow — a plain-text challenge that works exactly once, validated before any account or database work.

Endpoint Method Purpose
/auth/v1/oidc/session POST Session bootstrap.
/auth/v1/oidc/sessioninfo GET Session document.
/auth/v1/oidc/sessioninfo/xsrf GET Session CSRF token re-issue.

Login protection (per-address blocking and credential-stuffing detection) is configured under [events.login_protection]; see Security notes.

When a deployment serves several tenants, people are sent to the right sign-in by their e-mail address. None of this applies to a deployment that serves one tenant: its login page stays as described above.

An application that already knows who is signing in (because the person typed their address on the application’s own page, for example) adds login_hint=<address> to the authorization request. TinyGuard keeps it only when it is an e-mail address: one @, at most 254 characters, no spaces, control characters or angle brackets. Anything else is ignored and the sign-in goes on as if no hint had been sent. The login page shows the address already filled in, with Use another address.

When the person continues to an upstream identity provider, TinyGuard passes the address on as login_hint, so the company’s sign-in page does not ask for it again. It also adds domain_hint=<domain> for a Microsoft Entra ID provider when the domain is a verified sign-in domain of the tenant, and hd=<domain> for a Google provider with a Workspace domain set. Turn this off for one provider with Forward sign-in hints in its editor. SAML providers get no hints.

A tenant with at least one verified sign-in domain asks for the address first:

  1. Email address and Continue. Sign in another way below them holds the tenant’s provider buttons and the passkey sign-in.
  2. What comes next follows the address’s domain: straight to the company’s identity provider, a choice between the providers the tenant lists for that domain and Use my password, or the password page.

Every address that is not at one of the tenant’s verified domains gets the password page, whether or not an account exists for it, so the first step reveals nothing about who has an account. A hint skips the first step. The tenant setting sign_in.email_first is auto (the default: on while the tenant has a verified domain) or off (the classic page with everything on one screen). Client layouts keep working: each step renders inside the auth-content slot.

An address at a verified domain whose target is a provider cannot register a password account or set a password through the reset page: the person is told “Use your company sign-in.” with a link to it. So a company’s employees always go through their company directory.

People who do not know their tenant’s address start at the deployment’s shared sign-in page, /auth/v1/sign-in, which sends their address to the tenant that owns its domain. A tenant with its own hostname needs neither: its people sign in at that hostname directly.

With registration open ([users] registration_enabled = true), the sign-in page shows a Create an account link. It opens the registration page, which asks for an e-mail address and a password (first and last name are optional; a username too when [users] preferred_username_required = true). After Create account the page says to confirm the e-mail address, and the confirmation link arrives by e-mail. Back to sign-in returns to the sign-in page the person came from. An address at a company domain gets “Use your company sign-in.” with a button to it instead. With registration closed the link is not shown and the registration page does not exist.

Once an address is confirmed (through the link, by an administrator, or by a company directory that vouches for it), ID tokens and the userinfo answer carry email_verified: true; until then they carry false.

Self-registered accounts confirm their address

Section titled “Self-registered accounts confirm their address”

With registration open, a new account must confirm its e-mail address before it can sign in. Until then, the right password gets “Confirm your e-mail address first”, with Send the link again; a wrong password gets the usual answer, so nobody learns whether an unconfirmed account exists without knowing its password. A form linked from the login and registration pages also sends a new link, and always gives the same answer whatever the address. Each tenant chooses:

Setting Default Meaning
registration.require_email_confirmation on Off lets new accounts sign in at once, as before.
registration.confirmation_link_hours 24 How long a confirmation link works (1 to 168). A new link cancels the earlier ones.
registration.unconfirmed_account_days 30 An account never confirmed is removed after this many days, which frees its address. 0 keeps it.
registration.default_groups none Groups every self-registered account joins, for example customers.

Accounts created by an upstream identity provider or by an administrator are not affected. Administrators find these settings in the Users section and can resend the link or mark an address as confirmed.

Client-specific sign-in and sign-out screens

Section titled “Client-specific sign-in and sign-out screens”

The console’s client detail has a Sign-in / sign-out layout tab and a full-page screen editor. The same settings are available at GET/PUT /auth/v1/clients/{id}/screens: theme (dark or light), composition (centered or split), a six-digit hex accent_color, headings and descriptions for both screens, and show_client_logo. The optional logout_theme, logout_composition, and logout_accent_color and logout_show_client_logo override the sign-out screen independently; null inherits the sign-in theme, layout, accent, or logo visibility. Older saved records continue to inherit these values. login_template and logout_template may each contain an optional custom declarative layout (maximum 8 KiB). For example:

<screen>
<columns ratio="wide-left">
<panel tone="accent" align="center">
<client-logo/>
<eyebrow>Member access</eyebrow>
<heading size="large">Welcome back</heading>
<text tone="muted">Use your company account to continue.</text>
</panel>
<auth-content/>
</columns>
</screen>

The only accepted elements are screen, columns, stack, panel, eyebrow, heading, text, divider, optional client-logo, and exactly one auth-content slot. The optional logo slot places the uploaded client logo at that point rather than before the canonical form; it accepts no URL or attributes and renders nothing when that screen hides the logo. stack provides vertical spacing with gap set to small, medium, or large; divider is empty and draws a design-system hairline. The slot inserts the canonical authentication or logout form, including CSRF fields and browser logic. Only enumerated presentation attributes are accepted: columns ratio can be equal, wide-left, or wide-right; panel tone can be raised, plain, or accent, with align left or center; heading size can be small, medium, or large; and text tone can be normal or muted. Templates cannot contain arbitrary HTML, CSS, attributes, links, scripts, or external URLs; all heading/text content is escaped. This keeps client-authored layouts from executing code on the authentication origin. The primary action chooses black or white text from the configured accent’s luminance. A login screen reads its client’s settings. A logout confirmation reads them when the request includes client_id; otherwise it uses the default theme. Screen settings are tenant-scoped and are removed with the client.

The editor can preview unsaved sign-in and sign-out settings. Previews use the server’s authentication-page renderer but contain inert controls, run in a sandboxed frame, and do not change the saved client settings. The preview API is administrator-only: POST /auth/v1/clients/{id}/screens/preview with the draft settings and a screen value of login or logout.

Each screen also accepts optional custom_css and logout_custom_css fields (up to 32 KiB of input each; logout_custom_css inherits the sign-in CSS when absent). This is real CSS parsed by a real CSS parser (lightningcss) — never a regex — and only the sanitized, minified result is stored and rendered; the save response returns exactly that sanitized form plus a per-field count of removed rules, so designers always see what survived.

What survives:

  • Style rules, with every top-level selector rewritten to a descendant of the authentication card root — .custom-screen when that screen uses a custom template, .card otherwise. html, :root, and body therefore match nothing: page-level theming stays owned by the palette. Pseudo- classes and pseudo-elements (including :has()) survive the rewrite.
  • Declarations of any other kind: colors, spacing, borders, typography, animations, transitions, custom properties, and @media, @supports, @container, @layer, @starting-style, @nest, and @keyframes bodies.

What is stripped, and why:

  • url(), image-set(), src(), @import, and @font-face — no network egress from an authentication page; imagery comes from the palette and the uploaded client logo.
  • position and z-index — client CSS must never overlay or displace the canonical authentication form.
  • filter — its url() form leaks references.
  • behavior, -moz-binding, and expression() (legacy IE/JS vectors).
  • Any input the parser rejects is refused outright: the console shows the server’s refusal verbatim and nothing is saved.

Under a custom template the login page keeps its CSRF pairing token out of every attribute (it lives in the embedded JSON block the page driver reads), so custom CSS attribute selectors cannot read secrets; the hidden no-JS fallback field remains only on the preset layout.

Ceremony timing and policy live in the [webauthn] section: challenge expiry 60 s, pending ceremony lifetime 90 s, remembered second-factor trust 2160 h.

Endpoint Method Purpose
/auth/v1/users/{id}/webauthn GET List a user’s passkeys.
/auth/v1/users/{id}/webauthn/register/start POST Passkey registration start.
/auth/v1/users/{id}/webauthn/register/finish POST Passkey registration finish.
/auth/v1/users/{id}/webauthn/auth/start POST Passkey authentication start (user-specific).
/auth/v1/users/{id}/webauthn/auth/finish POST Passkey authentication finish (user-specific).
/auth/v1/users/webauthn_start POST Passkey authentication start (login-wide).
/auth/v1/users/webauthn_finish POST Passkey authentication finish (login-wide).
/auth/v1/users/{id}/webauthn/delete/{name} DELETE Delete a passkey by name.
/auth/v1/users/{id}/mfa_token POST Issue a modification token.
/auth/v1/users/{id}/self/convert_passkey POST Convert the account to passkey-only.

Passkey enrollment is also available through a magic-link branch backed by the identity area’s registration links, and an account may be converted to passkey-only (no password). With no_password_expiry_with_passkey = true, registering a first passkey clears the account’s password expiry.

Endpoint Method Purpose
/auth/v1/oidc/logout GET End-session page or hint-driven logout.
/auth/v1/oidc/logout POST Logout confirmation and backchannel-logout receiver.

Session termination can also be forced administratively through /auth/v1/sessions (see the API reference).