- Documents recent infrastructure cleanup (8 CTs destroyed, 35 DNS records removed, Headscale cleanup) - Adds 24 new runbooks covering Authentik, PeerTube, Meshtastic, RECON, Proxmox, Mailcow, Internet Archive, GPU routing - Adds project documentation for headscale, vaultwarden, peertube, matrix, mmud, advbbs, arr stack - Updates services.md, environment.md, caddy.md, authentik.md to match live infrastructure - Removes 4 deprecated runbook duplicates (canonical versions live in projects/) - Adds .gitignore for binary archives and editor temp files Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
17 KiB
Deploying CouchDB with JWT auth for Obsidian LiveSync via Authentik
LiveSync has native client-side JWT support that eliminates the need for a browser-based OIDC flow. The plugin generates and signs JWTs internally using a stored private key, sending Authorization: Bearer headers directly to CouchDB. This fundamentally changes the architecture: instead of proxying OIDC tokens, you provision per-user key pairs, configure CouchDB with the public keys, and distribute setup URIs containing the private keys. Authentik serves as the identity backbone for a provisioning service — not as a runtime token issuer. No one has publicly documented a complete LiveSync + SSO deployment, making this guide a synthesis of the Kishieel Keycloak series, CouchDB JWT internals, Authentik's claim customization, and the LiveSync plugin's JWT implementation.
CouchDB's JWT engine and the exact local.ini configuration
CouchDB 3.3+ includes a built-in JWT authentication handler requiring zero plugins. From Kishieel's Keycloak series and the official docs, here is the complete local.ini:
[couchdb]
single_node = true
[chttpd]
bind_address = 0.0.0.0
port = 5984
require_valid_user_except_for_up = true
authentication_handlers = {chttpd_auth, jwt_authentication_handler}, {chttpd_auth, cookie_authentication_handler}, {chttpd_auth, default_authentication_handler}
[jwt_auth]
required_claims = exp,iat
roles_claim_path = _couchdb\.roles
[jwt_keys]
; EC key for LiveSync plugin (ES512 with P-521 curve)
ec:livesync-user1 = -----BEGIN PUBLIC KEY-----\nMHYwEAYHK...AzztRs\n-----END PUBLIC KEY-----\n
; RSA key from Authentik JWKS (for service/API access)
rsa:authentik-kid-here = -----BEGIN PUBLIC KEY-----\nMIIBIjAN...IDAQAB\n-----END PUBLIC KEY-----\n
[chttpd_auth]
secret = generate-a-long-random-secret-here
[cors]
origins = app://obsidian.md,capacitor://localhost,http://localhost
credentials = true
headers = accept, authorization, content-type, origin, referer
methods = GET, PUT, POST, HEAD, DELETE
max_age = 3600
[admins]
admin = your-admin-password
Critical details on roles_claim_path: The backslash in _couchdb\.roles is mandatory. Without it, CouchDB interprets the dot as JSON nesting and looks for {"_couchdb": {"roles": [...]}} instead of the flat key {"_couchdb.roles": [...]}. This was a long-standing bug (issue #3176, #3758) that caused JWT roles to silently fail until the roles_claim_path syntax was added in CouchDB 3.3. The deprecated roles_claim_name setting did not have this problem but is ignored when roles_claim_path is set.
Key format in [jwt_keys] follows the pattern {algorithm}:{kid} = {value}. The algorithm prefix (hmac:, rsa:, ec:) is mandatory and prevents algorithm-confusion attacks. CouchDB reads the JWT header's alg claim to determine the prefix and the kid claim to select the specific key. If no kid is present in the JWT, CouchDB falls back to {algorithm}:_default. For asymmetric keys, the value is the PEM-encoded public key with literal \n replacing newlines. For HMAC, it's a base64-encoded secret. Since CouchDB 3.3, = characters in key names (common in base64 key IDs) are supported when the name-value separator uses spaces: rsa:kid-with-base64= = -----BEGIN....
On required_claims: By default this is empty, meaning CouchDB does not validate token expiration. Always set required_claims = exp at minimum. The sub claim is always mandatory regardless of this setting and maps directly to the CouchDB username.
Key rotation via the HTTP config API takes effect immediately without restart:
curl -u admin:password -X PUT \
"http://localhost:5984/_node/_local/_config/jwt_keys/ec:new-kid" \
-H "Content-Type: text/plain" \
-d '"-----BEGIN PUBLIC KEY-----\nMHYw...\n-----END PUBLIC KEY-----\n"'
However, CouchDB bug #5091 reports that PUT /_node/{node}/_config/jwt_keys/{key} returns HTTP 400 for valid PEM keys in some CouchDB versions. The workaround is writing keys to a .ini file in /opt/couchdb/etc/local.d/ and restarting via POST /_node/_local/_restart. Changes to local.ini directly always require a restart; API-based changes do not.
Kishieel's Keycloak pattern adapted for Authentik
Kishieel's two-part series provides the only complete, proven CouchDB + OIDC reference implementation. The architecture uses OpenResty (Nginx + Lua) as a proxy that performs OIDC authentication for browser clients and injects a Bearer token before forwarding to CouchDB. Here's how each component maps to the Authentik equivalent:
Keycloak groups → Authentik groups with attributes: Kishieel created Keycloak groups /couchdb/admins and /couchdb/users with a group attribute _couchdb.roles set to ["_admin"] and ["_user"] respectively. In Authentik, you'd create groups named couchdb-admins and couchdb-users with custom attributes {"couchdb_role": "_admin"} and {"couchdb_role": "_user"} respectively.
Keycloak protocol mapper → Authentik scope mapping: Kishieel used an oidc-usermodel-attribute-mapper with claim.name: "_couchdb\\.roles" (double-escaped backslash to produce a literal dot in the JWT claim). The mapper was multivalued: true and aggregate.attrs: true to collect roles from all groups. In Authentik, create a Scope Mapping under Customization → Property Mappings:
- Name:
CouchDB Roles - Scope name:
couchdb - Expression:
return {
"_couchdb.roles": list(set(
str(g.attributes.get("couchdb_role"))
for g in request.user.ak_groups.all()
if "couchdb_role" in g.attributes
))
}
This iterates all user groups, extracts the couchdb_role attribute where it exists, deduplicates, and returns it as the _couchdb.roles claim. Values returned by scope mappings are added as custom claims to both access tokens and ID tokens.
Keycloak client scope → Authentik OAuth2 provider scope: Kishieel created a couchdb client scope containing the mapper, then assigned it as an optional scope on both the couchdb-proxy (confidential) and couchdb-cli (public) clients. In Authentik, assign the scope mapping to your OAuth2 provider's Selected Scopes list alongside openid, profile, and email. Check "Include claims in id_token" in the provider settings.
Kishieel's Lua proxy script (access.lua) is the key innovation. It uses lua-resty-openidc to perform the full OIDC authorization code flow for browser requests, then sets Authorization: Bearer <access_token> before proxying to CouchDB. Critically, the Part 2 update added an early return: if the request already has an Authorization header (from a CLI or API client), the Lua script skips the OIDC flow entirely. This dual-path design — browser SSO via proxy, direct Bearer token for programmatic access — is the pattern to replicate.
LiveSync's native JWT: how the plugin signs its own tokens
The Obsidian LiveSync plugin has built-in JWT generation that changes the deployment model fundamentally. Instead of obtaining tokens from an IdP at runtime, the plugin stores a private key and signs short-lived JWTs client-side. The relevant plugin settings are:
| Setting | Type | Default | Purpose |
|---|---|---|---|
useJWT |
boolean | false |
Enable JWT authentication |
jwtAlgorithm |
string | "" |
JWT algorithm (e.g., ES512, RS256) |
jwtKey |
string | "" |
Private key in PEM format |
jwtKid |
string | "" |
Key ID matching CouchDB's [jwt_keys] entry |
jwtSub |
string | "" |
Subject claim → CouchDB username |
jwtExpDuration |
number | 5 |
Token lifetime in minutes |
Token lifecycle: Tokens are cached and reused until 10% of the expiration duration remains or 10 seconds, whichever is longer (capped at 1 minute maximum). With the default 5-minute expiration, tokens refresh at the 30-second mark. The plugin generates a new token by signing with the stored private key — no network call to an IdP.
Key generation for ES512 (P-521 elliptic curve):
# Generate private key
openssl ecparam -genkey -name secp521r1 -noout -out private_key.pem
# Extract public key
openssl ec -in private_key.pem -pubout -out public_key.pem
The private key goes into the plugin's jwtKey setting. The public key (with newlines escaped as \n) goes into CouchDB's [jwt_keys] as ec:<kid> = <pem>.
The setup URI gap: The generate_setupuri.ts script (at utils/flyio/generate_setupuri.ts) only accepts basic auth parameters (hostname, database, username, password, passphrase). It does not support JWT settings. GitHub issue #729 documents this limitation. To generate a setup URI with JWT config, you must construct the full settings object (including useJWT, jwtAlgorithm, jwtKey, jwtKid, jwtSub, jwtExpDuration), encrypt it with a passphrase, and format it as obsidian://setuplivesync?settings=[encrypted_data]. The encryption mechanism uses passphrase-based AES encryption.
Authentik configuration for service tokens and JWKS
OAuth2 provider setup at auth.echo6.co: Create an OAuth2/OIDC provider for the CouchDB service. Set the signing key to an RSA certificate (Authentik defaults to its self-signed certificate using RS256). The JWKS endpoint is at https://auth.echo6.co/application/o/<app-slug>/jwks/ and the OpenID configuration at https://auth.echo6.co/application/o/<app-slug>/.well-known/openid-configuration.
Token lifetimes are configurable per-provider. The access token defaults to 5 minutes (format: minutes=5), refresh token to 30 days. For a provisioning service that generates long-lived tokens, extend to hours=1 or more. The syntax accepts hours=1,minutes=30,seconds=0.
Getting tokens programmatically via client_credentials:
# Method 1: Client ID + Secret (auto-creates service account)
curl -X POST 'https://auth.echo6.co/application/o/token/' \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d 'grant_type=client_credentials' \
-d 'client_id=<client_id>' \
-d 'client_secret=<client_secret>' \
-d 'scope=openid profile couchdb'
# Method 2: Service account credentials
curl -X POST 'https://auth.echo6.co/application/o/token/' \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d 'grant_type=client_credentials' \
-d 'client_id=<client_id>' \
-d 'username=my-service-account' \
-d 'password=my-app-password-token' \
-d 'scope=openid profile couchdb'
Authentik supports client_credentials, password (ROPC — treated identically to client_credentials), authorization_code, refresh_token, implicit, and urn:ietf:params:oauth:grant-type:device_code. All endpoint URLs: token at /application/o/token/, authorize at /application/o/authorize/, device at /application/o/device/.
JWKS-to-PEM conversion for injecting Authentik's signing key into CouchDB is handled by the couchdb-idp-updater tool (GitHub: beyonddemise/couchdb-idp-updater). This NodeJS tool by Stephan Wissel periodically fetches the JWKS from an IdP's .well-known/openid-configuration, converts JWK keys to PEM format, and updates CouchDB's [jwt_keys] config. Due to bug #5091, it may need to write directly to an INI file rather than using the REST API. The manual conversion script (jwks2couch.mjs) is available as a GitHub gist. The process is: fetch JWKS → for each key, convert JWK to PEM using jwk-to-pem npm package → collapse newlines to \n → write to [jwt_keys] as rsa:<kid> = <collapsed-pem>.
Per-database security without the _users database
JWT users do not need to exist in CouchDB's _users database. The user context is constructed entirely from the JWT: sub becomes the username, and the roles claim provides roles. CouchDB never queries _users during JWT authentication.
The _security document controls per-database access:
{
"admins": {
"names": [],
"roles": ["_admin"]
},
"members": {
"names": ["specific-jwt-sub-value"],
"roles": []
}
}
Set this via:
curl -u admin:password -X PUT \
"http://localhost:5984/userdb-alice/_security" \
-H "Content-Type: application/json" \
-d '{"admins":{"names":[],"roles":["_admin"]},"members":{"names":["alice"],"roles":[]}}'
CouchDB matches the JWT sub against members.names and admins.names, and the JWT roles against members.roles and admins.roles. If _security has any members defined, only matching users can access the database. The members role grants read access to all documents and write access to non-design documents. The admins role additionally allows writing design documents and modifying _security.
Do not use couch_peruser with JWT. The Plexify article documents that CouchDB's built-in couch_peruser feature only auto-creates databases for admin users under JWT auth — requiring you to grant _admin to everyone, which is dangerous. Instead, create databases and set _security programmatically from a provisioning service using admin credentials.
Proven deployment patterns and what breaks
No one has publicly deployed LiveSync with full SSO end-to-end. GitHub discussion #484 captures the core problem: "For the auth, I use Authentik for my self hosted programs, however I am unsure if it will work with the obsidian extension since there is no user interface to login." The plugin runs inside Obsidian's Electron shell — it cannot redirect to a browser for an OIDC login flow.
Token expiration causes PouchDB replication failures. When a JWT expires, CouchDB returns 401 with a WWW-Authenticate: Basic header, triggering an unwanted browser auth popup in Electron. The Plexify article documented this and recommended suppressing the header via reverse proxy or CouchDB config.
CORS is the most common failure mode. Issue #628 documents that LiveSync does not send the Origin header on non-preflight requests, causing CouchDB's CORS handler to omit Access-Control-Allow-Origin from responses. The fix is configuring CORS in local.ini (shown above) rather than relying on the reverse proxy alone. Required origins: app://obsidian.md, capacitor://localhost, http://localhost.
The Caddy reverse proxy config for notes.echo6.co:
notes.echo6.co {
reverse_proxy couchdb:5984
header {
Access-Control-Allow-Origin "app://obsidian.md"
Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
Access-Control-Allow-Headers "Content-Type, Authorization"
Access-Control-Allow-Credentials "true"
Access-Control-Max-Age "86400"
}
@options method OPTIONS
handle @options {
respond 204
}
}
The recommended architecture for notes.echo6.co
Given the constraints — LiveSync can't do OIDC flows, but it can sign JWTs client-side — the architecture has three components:
1. CouchDB container at notes.echo6.co behind Caddy, configured with JWT auth handler, CORS, and per-user databases with _security documents.
2. A provisioning service (a small web app hosted on forge.echo6.co or as a Docker container) that:
- Is protected by Authentik forward auth (browser-based OIDC login)
- On first login, generates an EC key pair (ES512/P-521) for the user
- Creates a per-user CouchDB database (
userdb-<username>) - Sets the
_securitydocument to restrict access to that user'ssub - Injects the public key into CouchDB's
[jwt_keys]via the config API or INI file - Constructs and displays a setup URI containing all JWT settings (
useJWT: true,jwtAlgorithm: ES512,jwtKey: <private_key>,jwtKid: <kid>,jwtSub: <username>,jwtExpDuration: 5) - Encrypts the URI with a per-user passphrase and presents it as a clickable
obsidian://setuplivesync?settings=[...]link
3. couchdb-idp-updater sidecar (optional, only needed if you also want Authentik-issued JWTs accepted directly by CouchDB for API access). This periodically syncs Authentik's JWKS public keys into CouchDB's config.
The provisioning service is the critical custom component. It bridges the gap between Authentik's identity management and LiveSync's key-based JWT model. Users authenticate once through their browser via Authentik SSO, receive their setup URI, paste it into Obsidian, and from that point the plugin handles all authentication autonomously by signing its own tokens.
Conclusion
The deployment hinges on a non-obvious insight: LiveSync's JWT support is self-contained, not IdP-dependent. The plugin signs tokens locally using a stored private key, which means the OIDC provider's role shifts from runtime token issuer to user provisioning backbone. CouchDB's roles_claim_path = _couchdb\.roles with the escaped dot, required_claims = exp,iat, and per-kid key entries in [jwt_keys] form the server-side foundation. The Kishieel blog's Lua proxy pattern remains valuable for browser-based CouchDB admin access but is unnecessary for the Obsidian plugin itself. The main engineering work is building the provisioning service that generates key pairs, configures CouchDB databases, and outputs encrypted setup URIs — a task well-suited to a Claude Code automation prompt targeting Docker Compose on Contabo with Caddy as the edge proxy.