PKI / X.509 certificate authentication
Users authenticate with an X.509 client certificate instead of a password. Common in government and military deployments (CAC/PIV), and anywhere mutual TLS is already in place.
Configure it at Settings → Authentication → PKI (super_admin); values are stored in
auth_config and take effect without a restart, with the PKI_* environment variables as a
bootstrap seed and fallback.
How it works
Client (with cert) ──▶ Reverse proxy (mTLS termination) ──▶ OpenTranscribe backend
│ │ │
presents cert validates against CA, re-validates,
forwards cert and/or DN authenticates
in headers or provisions
The reverse proxy terminates mTLS. It is what vouches for the headers it forwards — which is why the trust configuration below is not optional.
Trust configuration — read this first
PKI_TRUSTED_PROXIES is required whenever PKI is enabledHeader-sourced PKI authentication is refused outright when no trusted proxy is allow-listed. A hardened deployment additionally refuses to start.
Previously a forwarded DN was accepted from any source with only a warning, and a DN header
alone could authenticate without a certificate ever being parsed. Set pki_trusted_proxies to
the address the backend sees the reverse proxy arrive from. Single addresses and CIDR ranges
are both accepted.
Use the narrowest range that covers the proxy — its own /32, or the container network it
runs on (docker network inspect <project>_default --format '{{range .IPAM.Config}}{{.Subnet}}{{end}}').
Do not paste a whole private range. This setting gates identity assertion, not just
X-Forwarded-For: with pki_mode: header (the default) a DN with no certificate at all is
accepted from any peer in the list, so 10.0.0.0/8 or 192.168.0.0/16 — the ranges ordinary
LAN routers hand out — let any device on that LAN sign in as any DN, including an admin one
(issue #620). If the backend's own port is published, keep it off the LAN too
(BACKEND_BIND_HOST=127.0.0.1), or a client can bypass the proxy entirely.
A DN header is trusted only when it arrives from a configured proxy, or alongside a certificate this process has itself validated.
pki_mode decides how much the proxy is trusted to do:
pki_mode | Meaning |
|---|---|
header (default) | A trusted proxy terminates mTLS and forwards the certificate and/or its DN |
mutual_tls | Same transport, but a bare DN assertion is refused even from a trusted proxy — the full certificate must be forwarded so this application validates it itself |
pki_mode used to be direct / broker / hybrid in the schema and header / mutual_tls
in the admin UI, so no value could match and every save of the PKI tab was rejected; no
backend code branched on it either way. It is now header | mutual_tls, and pki_auth.py
reads it.
pki_support_cac and pki_support_piv were removed (migration v377 deletes their stored
rows). They gated nothing: both the DoD CAC and the PIV CN formats are parsed for every
certificate, unconditionally, and always have been.
Configuration reference
| Field | Config key | Default |
|---|---|---|
| Enabled | pki_enabled | false |
| CA certificate path | pki_ca_cert_path | — |
| Certificate header | pki_cert_header | X-Client-Cert |
| DN header | pki_cert_dn_header | X-Client-Cert-DN |
| Trusted proxies | pki_trusted_proxies | — (required when enabled) |
| Mode | pki_mode | header |
| Admin DNs | pki_admin_dns | — |
| Verify revocation | pki_verify_revocation | false |
| Revocation soft-fail | pki_revocation_soft_fail | false |
| OCSP timeout (s) | pki_ocsp_timeout_seconds | 5 (1–120) |
| CRL cache (s) | pki_crl_cache_seconds | 3600 (1–604800) |
| Allow password fallback | pki_allow_password_fallback | true |
pki_admin_dns is a ;-separated list of full subject DNs that should receive admin — a DN
itself contains commas, so , cannot delimit multiple entries. DN matching is exact and
case-sensitive — copy the string straight out of the certificate.
Revocation checking
Turn on pki_verify_revocation and OpenTranscribe checks each presented certificate:
- OCSP first, using the responder URL from the certificate's own Authority Information Access extension. Real-time; results are cached per serial with LRU eviction.
- CRL as the cross-check, downloaded from the certificate's CRL distribution points and
cached for
pki_crl_cache_seconds.
There is no separate "OCSP responder URL" or "CRL endpoint" setting — both come from the certificate, which is what those extensions are for.
pki_revocation_soft_fail decides what happens when revocation status cannot be
determined (responder unreachable, no CRL published). It defaults to false — hard fail,
reject the certificate. Set it to true only if you accept that an outage at your CA makes
revoked certificates usable.
Password fallback
pki_allow_password_fallback is a deployment ceiling over the per-user
User.allow_local_fallback flag: effective permission is per-user AND this. It defaults to
true, so it adds no restriction on upgrade — the per-user flag already defaults to off and
remains the precise control. Setting it to false turns password fallback off for every pki
account at once without editing them individually.
An active super_admin is exempt from both. Auth configuration is super_admin-gated, so
the account that could undo a misconfiguration must not be locked out by it. Note the exemption
only helps if that account actually has a password path: auth_type='local', or pki with
allow_local_fallback set. Document that credential somewhere safe — in a PKI-only deployment
it is the only non-certificate way in.
MFA
A PKI user bypasses local MFA only when they authenticated with their certificate. If they fall back to a local password, MFA still applies.
Development and testing
API-level (no browser)
./scripts/pki/setup-test-pki.sh
Creates a root CA at scripts/pki/test-certs/ca/ca.crt, client certificates for testuser,
pkiadmin (plus admin, john.doe and jane.smith), and .p12 files for browser import
(password changeit). ./scripts/pki/generate-test-env.sh wraps this and derives a full
test-env fragment (PKI_ENABLED, PKI_TRUSTED_PROXIES, PKI_ADMIN_DNS, ports) from whatever
certificates already exist — see "No-.env PKI testing" below, the fastest path for either
testing style.
./scripts/pki/test-pki-auth.sh pkiadmin # gets admin
./scripts/pki/test-pki-auth.sh testuser # gets user
# or by hand, simulating what the proxy does
ADMIN_DN=$(openssl x509 -in scripts/pki/test-certs/clients/pkiadmin.crt -noout -subject | sed 's/subject=//')
curl -X POST http://localhost:5174/api/auth/pki/authenticate \
-H "X-Client-Cert-DN: $ADMIN_DN"
This only works with pki_trusted_proxies covering the address the request arrives from.
POST /api/auth/pki/authenticate is rate-limited and lockout-tracked like every other auth
route — it mints real access and refresh tokens.
Browser-based (requires nginx mTLS)
Browser PKI needs nginx to verify the client certificate — the Vite dev server can't do mTLS.
--with-pki works in both modes: dev loads docker-compose.pki-dev.yml (a built nginx in
front of the normal bind-mounted, hot-reloading dev backend — only the PKI frontend is a built
image, so a frontend change still needs ./opentr.sh rebuild-frontend), prod loads
docker-compose.pki.yml.
./opentr.sh start dev --with-pki # dev, real mTLS, bind-mounted backend
./opentr.sh start prod --build --with-pki # prod, build local code first
./opentr.sh start prod --with-pki # prod, use published images
Import a .p12 from scripts/pki/test-certs/clients/ (password changeit) and open
https://localhost:5182.
- macOS:
security import scripts/pki/test-certs/clients/pkiadmin.p12 -k ~/Library/Keychains/login.keychain-db -P changeit -A, then in Keychain Access set the private key's Access Control to allow all applications. - Chrome (Windows/Linux): Settings → Privacy and security → Security → Manage certificates → Import.
- Firefox: Settings → Privacy & Security → Certificates → View Certificates → Your Certificates → Import.
| Certificate | Role | |
|---|---|---|
pkiadmin.p12 | pkiadmin@example.com | admin |
testuser.p12 | testuser@example.com | user |
john.doe.p12 | john.doe@example.com | user |
jane.smith.p12 | jane.smith@example.com | user |
admin.p12 (admin@example.com) also exists but is not usable for admin-role testing: its email
collides with the seeded dev super_admin, and account linking refuses any
email-matched link onto a super_admin unconditionally.
No-.env PKI testing
scripts/pki/generate-test-env.sh generates a gitignored fragment
(scripts/pki/test-certs/pki-test.env + pki-test.compose.yml) that --with-pki loads
automatically — .env is never opened, read, or written by any of it. This is what
RUN_PKI_E2E=true pytest backend/tests/e2e/test_pki.py runs against.
./scripts/pki/generate-test-env.sh --print # inspect the resolved values first; writes nothing
./scripts/pki/generate-test-env.sh # write the fragment
./opentr.sh start dev --with-pki # (or start prod --build --with-pki)
Smart cards (CAC / PIV)
- Install the card-reader drivers.
- Configure the browser's PKCS#11 module.
- Insert the card before navigating to the login page.
# Chrome on Linux
sudo apt install opensc opensc-pkcs11
# Settings → Privacy → Security → Manage certificates → Security Devices
# Add: /usr/lib/x86_64-linux-gnu/opensc-pkcs11.so
Both the DoD CAC and PIV common-name formats are parsed for every certificate; there is nothing to enable.
Account linking
A certificate whose subject DN matches no account, but whose email matches an existing one, is
only linked when the source asserts that address is verified — and never to a super_admin.
See Account linking.
Production checklist
-
pki_trusted_proxiesset to the proxy address the backend sees -
pki_modechosen deliberately (mutual_tlsif a bare DN must never be enough) -
pki_ca_cert_pathpoints at your enterprise CA, mounted into the backend container -
pki_verify_revocation=truewithpki_revocation_soft_fail=false -
pki_admin_dnspopulated with exact subject DNs - A documented break-glass
super_admincredential - Certificate renewal planned before expiry
- CA private key protected — its compromise means fraudulent certificates
Troubleshooting
| Symptom | Check |
|---|---|
| "PKI authentication is not enabled" | Settings → Authentication → PKI. Database configuration overrides .env |
| Every certificate is refused with no parse attempt | pki_trusted_proxies is empty, so header-sourced auth is refused |
| A DN header alone is refused from a trusted proxy | pki_mode is mutual_tls; forward the full certificate |
| "Invalid or missing client certificate" | Certificate imported in the browser, proxy forwarding the headers, certificate not expired |
| "Certificate not accepted" | CA matches the issuer, validity dates, key-usage extensions |
| Valid certificate refused when the CA is unreachable | pki_verify_revocation is on and pki_revocation_soft_fail is false — that is the intended behaviour |
openssl verify -CAfile ca.crt user.crt # chain
openssl x509 -in user.crt -text -noout # details
openssl x509 -in user.crt -subject -noout # DN, for pki_admin_dns