Skip to main content

Authentication Overview

OpenTranscribe has six identity sources — local passwords, LDAP/Active Directory, OpenID Connect, SAML 2.0, PKI/X.509 certificates, and trusted-header (reverse proxy) authentication. They run simultaneously; each account records which one owns it in user.auth_type, so a deployment can have directory users, certificate users, proxy-asserted users and a handful of local accounts at the same time.

Everything on this page is configured in Settings → Authentication, which is super_admin-only. Configuration is stored in the auth_config table, secrets encrypted with AES-256-GCM, and takes effect without a restart.

Where a setting comes from

Every auth setting resolves in the same order:

database (auth_config) → environment variable → coded default

Once you save a panel, the database value wins. The environment variable is a bootstrap seed and a fallback, not an override. Two exceptions are called out where they apply: jwt_access_token_expire_minutes and jwt_refresh_token_expire_days are marked requires restart (cookie max-age is computed from them at import time).

Secrets are never returned by the API. A sensitive key comes back with config_value: null and an is_set boolean; the panel renders "a secret is configured — leave blank to keep it".

Identity sources

Sourceauth_typeGuide
Local passwordlocalthis page
LDAP / Active DirectoryldapLDAP setup
OpenID Connect (Keycloak, Authentik, Okta, Entra ID, Authelia, Auth0, Zitadel…)oidcOIDC setup
SAML 2.0samlSAML setup
PKI / X.509 client certificatespkiPKI setup
Trusted-header (reverse proxy)proxyTrusted-header setup

Directory groups can be mapped onto in-app groups and an in-app role — see IdP group mapping.

The identity-source model

The question this answers is: "our IdP owns identity — why can people still sign in with a password, and why can they still self-register?"

SettingWhereMeaning
local_enabledAuthentication → LocalMay accounts holding a local password authenticate at all
allow_registrationAuthentication → LocalMay anyone create their own account
user.auth_typeSettings → UsersWhich source owns this account
user.allow_local_fallbackSettings → Users (super_admin only)May this external account also use a local password
pki_allow_password_fallbackAuthentication → PKIDeployment ceiling over the per-user flag, for pki accounts

Rules the code enforces:

  • allow_registration cannot be true while local_enabled is false. Self-registration creates auth_type='local' accounts with a local password; with local login off, every account it minted could never sign in. The API rejects the combination with a 400 that names which switch to change first — and it re-checks the resulting state, so you cannot assemble the rejected combination one save at a time.
  • local_enabled does not hide the username/password form. LDAP authenticates through the same form, so the login page renders it whenever local_enabled or ldap_enabled is on.
  • Local password fallback is one rule, in one place. local always may; ldap never may (no local password is stored for a directory account, and the per-user flag does not override that); pki and oidc may only with the per-user opt-in, and for pki only if the deployment ceiling also allows it. An unrecognised auth_type is refused.
  • An external identity may not take over an existing account by email coincidence. See Account linking below.

The super_admin exemption

An active super_admin that has a local password path is never blocked by the deployment's own identity-source policy — neither local_enabled nor pki_allow_password_fallback applies to it. ("Has a local password path" means auth_type='local', or an external auth_type with allow_local_fallback set; a pki super_admin with the per-user flag off still has no password to fall back to.)

A super_admin with allow_local_fallback set is additionally exempt from account lockout — an emergency-access account that an attacker can lock out is the outage the exemption exists to prevent (NIST AC-7 permits this). Attempts are still recorded for audit.

This is deliberate and load-bearing, not a convenience: authentication configuration is itself super_admin-gated. Without the exemption, a deployment that turned off local login while its IdP was misconfigured would have no way back into the screen that undoes it.

Privilege tiers

user.role is the sole authorization truth — is_superuser is a derived mirror of role == super_admin, enforced by a database CHECK constraint. The dividing rule:

Anything that changes how the deployment runs, or that stores infrastructure credentials, is super_admin. Anything that manages users and their content is admin.

TierCovers
userOwn content, own settings, own MFA
adminUser accounts, tasks, search and speaker maintenance, data integrity, retention
super_adminAuthentication config, role changes, audit log, ASR provider, engine settings, backups, media mirror, watch sources, redaction policy
Changed in v0.5.0

Six panels moved from admin to super_admin: ASR provider, Engine configuration, Backups, Media Mirror, Watch sources, and the Redaction policy floor. If a plain admin administers any of those today, promote them before upgrading.

  • Creating more super_admins is a UI action. Settings → Users → Role, visible only to a super_admin, backed by an audited PUT /api/admin/users/{uuid}/role.
  • The last super_admin cannot be demoted or deleted.
  • External identity providers grant at most admin. super_admin is local-only, by design — it is the break-glass account for exactly the IdP that is failing. The cap is enforced in the service, in the Pydantic schema, and by a database CHECK constraint on the group-mapping table.

Admission control

Authentication answers "are you who you say you are". Admission answers "does this deployment want you". They used to be the same question for OIDC, which is how just-in-time provisioning ended up minting an account for every identity in a corporate tenant on first login.

ControlApplies toEmpty / off means
ldap_user_groupsLDAPNo group requirement
oidc_allowed_groups / oidc_blocked_groupsOIDCAdmit everyone
require_account_approval (Authentication → Local)Self-registration and every external JIT pathAccounts are usable immediately
  • An empty allow-list admits everyone. Reading it the other way would lock out every existing deployment on upgrade; only a non-empty list restricts.
  • Blocked means denied, not "exempt from the allow-list", and is evaluated first.
  • The OIDC group lists are semicolon-delimited — a directory group value is a DN and contains commas. Matching is case-insensitive exact, against the claim named by oidc_roles_claim.
  • The check re-runs on every login, so removing someone from the group locks them out rather than only affecting new users.
  • Refusals return the same generic 401 an unusable token gets — a distinct message would be an account-existence oracle. The reason goes to the audit log.

Approval queue

require_account_approval gives a newly provisioned account approval_status = pending instead of making it usable. This is not the same as is_active: deactivation revokes an account that was once usable, approval gates one that never has been.

Enforcement is the same lifecycle gate as the rest of this section — 403 with detail.code of account_pending_approval or account_rejected, not a second mechanism. Administrators clear the queue at GET/POST /api/admin/user-approvals (admin tier — managing users; the switch that creates the queue is auth config, hence super_admin).

Turning the setting off releases pending accounts. Rejected stays rejected, and rejection never deletes the row. The bootstrap super_admin is never created pending, so a first-boot deployment cannot lock itself out of its own queue.

Account lifecycle

Invitations

Settings → Users → Invite. An admin names an address plus the target role and auth_type; the recipient gets an emailed link, proves control of the address, and chooses their own credential — or is handed straight to the IdP when auth_type is external.

This is the supported way to onboard when self-registration is off. Invitation tokens are SHA-256 hashed at rest, single-use, and expiring; every rejection (unknown, expired, revoked, already used, address already registered) returns exactly the same message.

Email verification

require_email_verification (Authentication → Local, category local) gates local password login only. An account whose identity lives in LDAP/OIDC/SAML/PKI/proxy has its address asserted by the provider, so blocking those logins here would second-guess the IdP. Verification tokens expire after 24 hours and are rate-limited to 3 issues per hour.

Forced password change

user.must_change_password confines the account to PUT /users/me (the self-service password change) and the logout routes. Everything else returns 403 with a machine-readable detail.code == "password_change_required". The flag is set by an admin password reset, and automatically when a local password exceeds password_max_age_days.

An account whose password_changed_at is NULL is not forced through a change — the column was never stamped on older accounts, and forcing all of them at once would be a self-inflicted outage. The backend logs a warning naming the account instead.

Account expiry

user.account_expires_at time-boxes an account (a contractor, an auditor). Past that instant every request returns 403 with detail.code == "account_expired". There is no exempt route: unlike a forced password change there is no self-service remedy, so the caller is not routed to a screen that cannot help them. The denial is audited.

Login banner acknowledgment (FedRAMP AC-8)

With login_banner_enabled on, a user who has not acknowledged the banner is refused on every non-exempt route with 403 detail.code == "banner_acknowledgment_required". The exempt set is the banner itself, the acknowledgment endpoint, and both logout routes.

An acknowledgment expires when the banner text changes. The comparison is against the login_banner_text config row's updated_at, so editing the wording re-asks everyone. A user who accepted "UNCLASSIFIED — monitoring in effect" has not accepted a later "SECRET — no personal use".

Sessions

A session is a refresh_token row. Concurrent-session limits, rotation, revocation and the idle/absolute timeouts all key off those rows; there is no second session store.

SettingDefaultBounds
jwt_access_token_expire_minutes (requires restart)601–1440
jwt_refresh_token_expire_days (requires restart)71–365
session_idle_timeout_minutes151–1440
session_absolute_timeout_minutes4801–10080
max_concurrent_sessions50–1000 (0 = unlimited)
concurrent_session_policyterminate_oldestterminate_oldest | reject
  • Idle timeout is checked at refresh, not per request. Polling endpoints (progress, notifications, task status) and WebSocket keepalives would reset a per-request activity clock continuously, so the control would read as satisfied and never fire. The granularity is therefore one access-token lifetime.
  • The absolute timeout is carried forward, never recomputed. It is the only thing that caps a client that refreshes forever. Both timeout columns are nullable and un-backfilled — NULL means "no cap recorded", is treated as valid, and is stamped on the row's first rotation, so upgrading does not sign everyone out a second time.
  • Hitting the concurrent cap is audited, whether the policy evicted the oldest session or rejected the new one (reject returns 429).
  • Users see and revoke their own sessions in Settings → Profile → Active sessions; an admin sees and revokes another user's via GET/DELETE /api/admin/users/{uuid}/sessions.

Directory-sync deprovisioning (LDAP)

A periodic sweep asks the directory whether each auth_type='ldap' account still exists and is still enabled, disables the ones that are gone, and revokes their sessions. See LDAP → Directory sync. Disabling without revoking would leave a refresh token rotating indefinitely, so the revocation is the half that actually closes the hole.

Account linking

Every external source resolves a user by its own identifier first (ldap_uid, oidc_subject, pki_subject_dn) and only then falls back to matching on email address. That fallback is an account-takeover vector, because the address is an attribute of the external source: whoever can write it — a directory administrator, a self-service directory, anyone who can get a certificate issued — could point it at an existing account and inherit it.

One rule, used by LDAP, OIDC, SAML, PKI and trusted-header (proxy) alike:

  1. Link on an email match only when the source asserts the address is verified.
  2. Never link a super_admin account by email, verified or not.

A refusal fails the login — it does not fall through to creating a second account, because that would either collide on the unique email index or leave two accounts for one person. It is audited as an AUTH_LOGIN_FAILURE with error_code ACCOUNT_LINK_REFUSED, and it surfaces to the caller as the same generic failure that path returns for a bad credential, so it cannot be used to probe which addresses exist.

Operator remedy: link the account deliberately instead of by coincidence — set the account's provider identifier from the admin UI, or change one of the two addresses.

Behaviour change in v0.5.0

Authentik hardcodes email_verified to false and Entra ID omits the claim entirely. On those providers an OIDC login will no longer take over a pre-existing local account with the same address. Use one of the two remedies above.

Multi-factor authentication

TOTP per RFC 6238 (Google Authenticator, Authy, Microsoft Authenticator), with one-time backup codes.

SettingDefault
mfa_enabledfalse
mfa_requiredfalse
mfa_issuer_nameOpenTranscribe
mfa_backup_code_count10
mfa_token_expire_minutes5
  • mfa_required is enforced at the server, not just in the SPA. A user who has not enrolled receives a short-lived, enrolment-scoped half-token that authorizes only /mfa/setup and /mfa/verify-setup; completing enrolment issues the session. An API client that ignores the hint gets nothing.
  • Every token carries a purpose claim and every consumer checks it. Access, refresh and MFA tokens are signed with the same key, so the claim is the only thing separating them.
  • PKI and OIDC users bypass local MFA only when they used their native method. If they fall back to a local password, MFA applies.
  • TOTP codes are single-use; the MFA half-token's JTI is blacklisted after verification.

Password policy, lockout, rate limiting

SettingDefaultBounds
password_policy_enabledtrue
password_min_length128–128
password_require_uppercase / _lowercase / _digit / _specialtrue
password_history_count240–100 (0 disables)
password_max_age_days600–3650 (0 = never expires)
account_lockout_enabledtrue
account_lockout_threshold51–1000
account_lockout_duration_minutes151–10080
account_lockout_progressivetrue
account_lockout_max_duration_minutes14401–525600
rate_limit_enabledtrue
rate_limit_auth_per_minute101–10000
  • Lockout is keyed on a canonical identifier per account, so an account reachable by both an email address and an LDAP uid gets one budget, not two.
  • Rate limiting is per-IP, resolved through the trusted-proxy chain.
  • The dev stack relaxes both (docker-compose.override.yml: 120 requests/min, lockout threshold 100). Production never loads that overlay.

Transactional auth email

Password resets, invitations and verification links need a working mail transport. Which one is an explicit choice: Settings → Watch Sources → Email configurations holds the provider rows, and one of them is designated to carry authentication mail (PUT /api/admin/auth-config/email/designation, super_admin).

  • Clearing the designation falls back to the SMTP_* environment transport.
  • A designation naming a row that does not exist, or a disabled row, is rejected at write time rather than failing silently later.
  • Deleting or disabling the designated row is refused while it holds the designation.

Audit

Authentication and administrative events go to an OpenSearch-backed audit log, viewable at Settings → Audit Logs (super_admin). Separately, Settings → Authentication → Audit shows changes to the auth configuration itself, read from the auth_config_audit table in Postgres — including who made each change.

SCIM 2.0 provisioning

For deployments that want their IdP to create, update and deactivate accounts directly (rather than relying on JIT provisioning at login), OpenTranscribe exposes a SCIM 2.0 (RFC 7643/7644) endpoint at /scim/v2 — mounted at the application root, not under /api, because RFC 7644 §3.1 fixes the base path and every connector appends /Users / /Groups to it itself.

Authentication is a bearer token (scim_token, hashed at rest) issued and revoked by a super_admin at Settings → Authentication → SCIM (/api/admin/scim-tokens). A SCIM connector cannot create or touch a super_admin, and cannot write a role; group membership it creates is tagged source='scim', which is shielded from directory-sync reconciliation and vice versa.

Filter support

Exactly one filter shape is implemented: <attribute> eq "<value>", on userName or externalId for /Users, and on displayName for /Groups. Anything else — co, sw, boolean and/or, or a filter on any other attribute — is refused with 400 invalidFilter rather than silently ignored or partially applied. A partial filter implementation would return a result set a connector then acts on as though it were complete, which is worse than refusing outright.

Not rate-limited, deliberately

Every other authenticated surface in OpenTranscribe carries a per-IP rate limit. /scim/v2 does not: the credential is 256 random bits rather than a guessable password, an IdP synchronizes an entire directory from a small pool of egress IPs and can legitimately burst hundreds of requests in a short window, and a per-IP limit would throttle the tenant's own directory sync rather than an attacker. No SCIM handler carries the rate-limiter decorator.

Soft-disable semantics

Both PATCH .../Users/{id} with active: false and DELETE .../Users/{id} disable the account and revoke its sessions — neither one deletes it. DELETE is a soft-disable, not a hard delete: a connector dropping someone from its assignment scope (removed from an Okta app group, for example) must not erase their transcripts, only their access. Re-adding the same identity to the connector's scope re-enables the same account rather than minting a duplicate.

See backend/app/auth/CLAUDE.md for the exact PATCH surface that is supported — it is a closed, explicitly documented list; an unsupported path is 400 invalidPath, never a silent 200 for a change that did not happen.

Compliance

RequirementImplementation
FedRAMP IA-2MFA (server-enforced), PKI authentication
FedRAMP IA-5(1)Password policy, history, expiry → forced change
FedRAMP AC-2Account expiry, invitations, directory-sync deprovisioning
FedRAMP AC-2(3)last_login_at stamped on every successful authentication
FedRAMP AC-7 / NIST 800-53 AC-7Progressive account lockout
FedRAMP AC-8Login banner, enforced acknowledgment
FedRAMP AC-10Concurrent-session limit, audited
FedRAMP AC-12Refresh-token rotation, revocation, idle + absolute timeouts
FedRAMP AU-2 / AU-3Audit logging of authentication and admin events
FedRAMP SC-28Auth-config secrets encrypted at rest (AES-256-GCM)

Next steps