Ultimate Certificate Manager implements comprehensive security features to protect your PKI infrastructure.
All private keys (CA and certificate) are encrypted at rest using Fernet encryption (AES-256-CBC with HMAC-SHA256).
Private key encryption is managed from Settings > Security in the web UI. The master key is stored at /etc/ucm/master.key.
Alternatively, via API (using session cookies):
# Encrypt existing keys (dry run first)
curl -k -b cookies.txt -X POST https://localhost:8443/api/v2/system/security/encrypt-all-keys \
-H "Content-Type: application/json" \
-d '{"dry_run": true}'
# Then actually encrypt
curl -k -b cookies.txt -X POST https://localhost:8443/api/v2/system/security/encrypt-all-keys \
-H "Content-Type: application/json" \
-d '{"dry_run": false}'- Keys stored encrypted in database with
ENC:prefix - Decrypted only when needed (export, signing)
- Original keys never logged
When private-key encryption is enabled, UCM does not keep plaintext key files in the data/private/ directory. Existing mirrors are removed when encryption is enabled and on every start; public certificate files remain untouched. If a local service needs certificate and key files, use a deploy hook, including a same-host SFTP target for services running on the UCM host. Private-key export through the API remains permission-gated by read:private_keys.
When KEY_ENCRYPTION_KEY enables encryption through the environment, the same startup purge applies. Move any consumer that reads data/private/ directly to a deploy hook or the API before restarting with this setting.
Cross-Site Request Forgery protection for all state-changing requests.
- Login/verify response includes
csrf_token - Client stores token in
sessionStorage - Client sends
X-CSRF-Tokenheader on POST/PUT/DELETE/PATCH - Server validates token signature and expiry
timestamp:nonce:hmac_signature
- Valid for 24 hours
- Signed with SECRET_KEY
/api/v2/auth/login(needs to get token)/acme/,/scep/,/ocsp,/cdp/(protocol endpoints)/api/health(monitoring)
Strong password enforcement for all user accounts.
| Rule | Value |
|---|---|
| Minimum length | 8 characters |
| Maximum length | 128 characters |
| Uppercase required | Yes |
| Lowercase required | Yes |
| Digit required | Yes |
| Special character required | Yes |
| Special chars allowed | `!@#$%^&*()_+-=[]{} |
- Common passwords (password123, admin, etc.)
- 4+ sequential characters (abcd, 1234)
- 4+ repeated characters (aaaa, 1111)
# Get policy
GET /api/v2/users/password-policy
# Check strength (returns score 0-100)
POST /api/v2/users/password-strength
{"password": "MyP@ssw0rd!"}Protection against brute force and DoS attacks.
| Endpoint Pattern | Requests/min | Burst |
|---|---|---|
/api/v2/auth/login |
10 | 3 |
/api/v2/auth/register |
5 | 2 |
/api/v2/certificates/issue |
30 | 5 |
/api/v2/cas |
30 | 5 |
/api/v2/backup |
5 | 2 |
/api/v2/users |
60 | 10 |
/api/v2/certificates |
120 | 20 |
/acme/, /scep/ |
300 | 50 |
/ocsp, /cdp/ |
500 | 100 |
| Default | 120 | 20 |
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 115
X-RateLimit-Reset: 1706789123
{
"success": false,
"error": "Rate limit exceeded",
"retry_after": 45
}# Get config and stats
GET /api/v2/system/security/rate-limit
# Add IP whitelist
PUT /api/v2/system/security/rate-limit
{"whitelist_add": ["192.168.1.100"]}
# Reset counters for IP
POST /api/v2/system/security/rate-limit/reset
{"ip": "192.168.1.50"}Comprehensive logging of all security-relevant actions.
- Authentication (login, logout, failures)
- User management (create, update, delete)
- Certificate operations (issue, revoke, export)
- CA operations (create, delete, sign)
- Settings changes
- Security events (rate limited, permission denied)
# Get retention settings
GET /api/v2/system/audit/retention
# Update retention (days)
PUT /api/v2/system/audit/retention
{"retention_days": 365, "auto_cleanup": true}
# Manual cleanup
POST /api/v2/system/audit/cleanup
{"retention_days": 90}Default: 90 days, auto-cleanup daily at midnight.
Proactive email notifications before certificates expire.
- 30 days before expiry
- 14 days before expiry
- 7 days before expiry
- 1 day before expiry
# Get settings
GET /api/v2/system/alerts/expiry
# Update settings
PUT /api/v2/system/alerts/expiry
{
"enabled": true,
"alert_days": [30, 14, 7, 1],
"recipients": ["admin@example.com"]
}
# List expiring certificates
GET /api/v2/system/alerts/expiring-certs?days=30
# Manual check
POST /api/v2/system/alerts/expiry/checkRequires SMTP configuration in Settings > Email.
Two guards, because the two jobs have different answers.
Network scan endpoints (discovery, SSL checker) use validate_url_not_private:
- Private/loopback IP ranges blocked by default (127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)
- DNS rebinding protection: resolved IPs are validated against the blocklist
- Configurable allowlist for internal network scanning use cases
User-supplied outbound URLs (webhooks, SSO, ACME, imports) use validate_url_not_cloud_metadata, which blocks cloud metadata and loopback while deliberately allowing the LAN. UCM is deployed on premises: an RFC1918 webhook or an internal SSO endpoint is the primary use case, not an attack.
- Cloud metadata endpoints always blocked: the link-local address every major provider answers on, plus the ones that sit elsewhere (Oracle Cloud, Scaleway, and Google's and Alibaba's named forms)
- An IPv4 address written as IPv6 is read back and judged the same way, in each of the six forms that reach the same host: mapped, the deprecated compatible form, 6to4, Teredo, ISATAP and NAT64. For NAT64's local-use prefix, where the specification allows several layouts and the address does not say which is in use, every layout is read and judged against the metadata endpoints
WebAuthn authentication includes rate limiting:
- Failed attempts tracked per user
- Account lockout after repeated failures
- Separate rate limit from password login
All SSO authentication events are logged:
- Login attempts (success/failure) with provider type
- LDAP bind errors (generic messages to prevent enumeration)
- OAuth2/SAML token validation failures
- Session creation from SSO providers
A consolidated audit landed in v2.142. All changes are operator-transparent except where noted in the right column.
| Area | Change | Action required |
|---|---|---|
| HSM runtime installer | POST /api/v2/hsm/install-dependencies is opt-in (UCM_ALLOW_RUNTIME_PIP=1), returns 403 by default |
Set env var or install python3-pkcs11 / PyKCS11 via OS package manager. See HSM_DOCKER.md. |
| Session directory perms | Boot refuses to start if perms are not 0o700 (RuntimeError: Refusing to boot: session dir <path> has perms <oct>, expected 0o700) |
DEB/RPM/Docker handle this; manual installs must chown ucm:ucm <dir> && chmod 0700 <dir> |
| Reverse-proxy mTLS (mTLS / EST / SCEP) | Proxy-injected X-SSL-Client-* headers only honoured from CIDRs in security.trusted_proxies |
Set security.trusted_proxies if you terminate TLS on a reverse proxy; direct deployments unaffected |
CSV bulk import (/api/v2/users/import) |
Capped at 5 MB / 10 000 rows, returns 413 on overflow |
Split larger imports |
CRL on-demand (/cdp/<ca>.crl) |
Per-CA serialisation lock, returns 503 + Retry-After: 5 under contention |
None, clients honour Retry-After. Cached CRL endpoint unaffected. |
| Webhooks | DNS revalidated at delivery time (closes DNS-rebinding window between config and delivery) | None. RFC1918/.lan/.local still allowed by design (on-prem) |
| 2FA backup codes | Hashed at rest (Argon2id), atomic single-use consumption (UPDATE ... WHERE hash = ? AND used_at IS NULL) |
None: re-generate codes after upgrade for fresh hashes |
| Approval quorum | Per-request DB lock, recount-in-transaction, idempotent re-submit | None |
| ACME account keys | Encrypted at rest with master key | None: migrated transparently on first read |
| Audit IP | client_ip() honours X-Forwarded-For only behind trusted proxy |
None: same as mTLS row |
| SCEP CSR copy | KU/EKU stripped to whitelist (digitalSignature, keyEncipherment, serverAuth, clientAuth); arbitrary client-supplied bits are dropped |
Use templates/policies for non-default usages |
| EST endpoints | Per-request est_enabled check returns 503 EST disabled instead of falling through to SPA HTML |
None |
| Database commits | All api/v2/* go through safe_commit() (rollback + log on failure) |
None |
The hardening pass also exposed reusable helpers in backend/utils/:
| Helper | Module | Purpose |
|---|---|---|
is_request_from_trusted_proxy() |
trusted_proxy |
Bool: true only if request comes from security.trusted_proxies CIDR |
client_ip() |
trusted_proxy |
Resolves XFF only behind trusted proxy, else remote_addr |
reject_untrusted_proxy_headers() |
trusted_proxy |
401 helper for routes consuming proxy-injected headers |
validate_url_not_cloud_metadata() |
ssrf_protection |
Default for user-supplied outbound URLs (webhooks, SSO, ACME proxy, imports) |
validate_url_not_private() |
ssrf_protection |
Strict, only when target MUST be public Internet (rare) |
safe_commit() |
safe_commit |
Wrapped db.session.commit() with rollback + log |
audit_event(action=..., ip=client_ip(), ...) |
audit |
Shorthand for AuditService.log_action |
require_json_body |
validation |
Decorator: 400 if body is missing/invalid JSON |
parse_request_pagination() |
validation |
(page, per_page) from query string with bounds |
SSRF policy reminder. UCM is on-prem. RFC1918, loopback,
.lan/.local/.corpare the primary use case, not an attack vector. Usevalidate_url_not_cloud_metadata(blocks cloud-metadata + loopback only) for any user-supplied outbound URL, nevervalidate_url_not_private, which would break LAN webhooks, internal SSO, and local ACME validation.
Second consolidated audit. Operator-transparent unless noted.
| Area | Change | Action required |
|---|---|---|
| OCSP (RFC 6960) | Mixed-format serial lookup, cache invalidation on revoke, correct keyHash, nonce bypasses cache, delegated responder must carry id-pkix-ocsp-nocheck |
None |
| CRL (RFC 5280) | Mixed-format serial handling, no silent truncation of serials >159 bits, auto-regen of expired CRL on CDP fetch; AKI from signing CA SKI (§5.2.1, #202); IDP omitted on base+delta; FreshestCRL guarded; omit unspecified reasonCode; removeFromCRL delta-only; optional invalidityDate; unhold emits delta removeFromCRL when delta CRL enabled |
Regenerate intermediate CRLs after upgrade |
| Cert profile (RFC 5280) | 5 issues fixed in CA/CSR signing paths (SKI/AKI format, BasicConstraints, EKU consistency, KU bit ordering, validity bounds); SKI/AKI never copied from CSR (forced from subject key + issuer SKI); CA AIA includes caIssuers when configured |
NameConstraints IP subset and policy/anyPolicy product choices remain |
| ACME (RFC 8555/8737) | EAB JWK match via thumbprint, JWS algorithm allowlist (asymmetric only), wildcard restricted to DNS-01, ALPN extension marked critical, case-insensitive domains; pre-authz §7.4.1 (migration 033) |
None |
| TSA (RFC 3161/5035) | signing-certificate-v2 mandatory, body cap 64 KiB, correct PKIStatus separation |
None |
| EST (RFC 7030) | serverkeygen encrypts the generated key under the client mTLS pubkey, not the issued cert |
None |
| SCEP (RFC 8894) | Renewal rejected when signer cert expired or not yet valid | None |
| CA/cert/CSR APIs | Whitelisted key params, capped validity (≤3650 d), URL validation (CRL DP / AIA / OCSP / IDP), HSM key lock on bind, EC curve whitelist, CSR proof-of-possession (is_signature_valid) |
None |
| RBAC | Reserved role names rejected (admin/operator/viewer), permission whitelist with wildcard, system roles immutable |
Use custom role names |
| SSO OIDC | PKCE (S256) + nonce on auth flow | None |
| HSM | Provider secrets encrypted at rest, sign payload cap 1 MiB, FK-guarded deletes | None |
| MSCA | Fail-closed encryption, EOBO admin gate, audit, size caps | None |
| Webhooks | Secret encrypted at rest, event allowlist, reserved headers locked, events ≤64 per webhook | None |
| Discovery | Port validation, IPv6 subnet cap (≤1024), update_profile gated |
None |
| Audit | Trusted-proxy XFF, post-cleanup integrity check | None |
| Reports / SSH / Trust store | Param caps, principal/extension caps, PEM size cap (256 KB), sync limit 1–1000 | None |
| EAB | HMAC keys encrypted at rest | None |
| Imports | CA / certificate import paths now encrypt private keys (silent regression fix) | None |
| Decoder tools | tools/decode-csr and tools/decode-cert capped at 256 KiB → 413 |
None |
| Users | Self-change requires current password; ≥1 active admin invariant enforced |
None |
Operators can now take any CA offline to prevent unauthorized signing. Two modes are exposed:
| Mode | Where the key lives | Restore input |
|---|---|---|
password_protected |
UCM database, re-wrapped with BestAvailableEncryption(password) (PKCS#8) under the existing master-key wrap (two layers at rest) |
Password only |
file_exported |
Returned to the operator as a single-layer password-encrypted PKCS#8 PEM; ca.prv set to NULL in the database |
Password + re-uploaded .key.pem |
Threat model:
- Stolen DB only:
password_protectedkeys remain encrypted under both the master key and the offline password.file_exportedkeys are absent entirely. - Stolen DB + master key:
password_protectedkeys still require the offline password.file_exportedkeys are absent. - Stolen offline file: useless without the password (PKCS#8 password-encrypted; standard cryptography library hardening).
- Forgotten password: no recovery. The CA is unrecoverable. This is by design.
Sign/issue/CRL paths gate on ca.offline (see services/ca/ca_signing.py:31, csrs.py:689, services/cert/mixins/csr.py, crl.py:97). The update_ca endpoint can no longer flip the offline flag, only the dedicated take_offline / restore endpoints do, both of which require the password.
Audit actions: ca.offline.password_protected, ca.offline.file_exported, ca.restore.password_protected, ca.restore.file_exported. The legacy free-text "offline reason" field is no longer collected, the mode IS the audit record.
Password policy for the offline password is the same as the user password policy (12+ chars, mixed classes, no 4+ sequential).
# 1. Change default admin password immediately
# 2. Generate and set encryption key
# 3. Configure HTTPS with proper certificate
# 4. Set strong SECRET_KEY in /etc/ucm/ucm.env# /etc/ucm/ucm.env
SECRET_KEY=<random-64-char-string>
KEY_ENCRYPTION_KEY=<fernet-key>
FLASK_ENV=production- Run behind reverse proxy (nginx, Caddy)
- Enable firewall, restrict access to port 8443
- Use proper TLS certificate (not self-signed in production)
- Encrypted backups include encryption key
- Store backups securely off-server
- Test restore procedures regularly
Access security metrics at Settings > Audit Logs:
- Failed login attempts
- Rate limited requests
- Permission denied events
- Certificate operations
| Task | Interval | Description |
|---|---|---|
audit_log_cleanup |
Daily | Remove old audit logs |
cert_expiry_alerts |
Daily | Send expiry notifications |
crl_auto_regen |
Hourly | Regenerate expiring CRLs |
If you discover a security vulnerability, please report it responsibly:
- Do NOT create a public GitHub issue
- Open a GitHub Security Advisory
- Include: description, steps to reproduce, impact assessment
- Allow 90 days for fix before public disclosure
| Version | Date | Changes |
|---|---|---|
| Unreleased | 2026-07-17 | CSR SKI/AKI never copied from enrollee CSR; EE/CA AKI from issuer SKI; CA AIA caIssuers; CRL invalidityDate; unhold → delta removeFromCRL |
| 2.56 | 2026-07-17 | ACME/CSR certificates now include Extended Key Usage (serverAuth), empty subject populated from SAN |
| 2.55 | 2026-07-17 | DN format fix (RFC 4514), auto-migration corrects existing records |
| 2.52 | 2026-07-14 | SSRF protection, DNS rebinding prevention, WebAuthn brute-force protection, SSO audit logging, certificate discovery security |
| 2.1.0 | 2026-02-19 | SSO (LDAP/OAuth2/SAML) with rate limiting, LDAP filter injection fix, CSRF on SSO endpoints, 4-role RBAC (admin/operator/auditor/viewer), 28 SSO security tests |
| 2.0.2 | 2026-01-31 | Private key encryption, CSRF, password policy, rate limiting |
| 2.0.0 | 2026-01-29 | Initial security framework, session auth, RBAC |