Your Password Manager Has an Open Door
You self-hosted Vaultwarden. Smart. You turned on TOTP inside the Bitwarden client. Also smart. You patted yourself on the back and called it hardened. But that 2FA lives inside Vaultwarden’s application layer, which means it’s only as solid as the login flow in the web vault and the mobile apps.
The /admin panel? It has its own authentication entirely, a single admin token, no 2FA available. The signup page? Wide open if you forgot to flip SIGNUPS_ALLOWED=false. And the mobile clients, once they’ve logged in, hit /api/sync with a cached refresh token, no 2FA prompt in sight.
None of that is a bug. It’s how Bitwarden’s architecture works. But if you’re running this thing exposed to the internet, you want a second layer in front of the parts that can take one. Authelia is that second layer.
Authelia is an open-source authentication and authorization server that runs alongside your reverse proxy. You wire it in via ForwardAuth, and it enforces 2FA (TOTP, WebAuthn, push) before the request reaches Vaultwarden. It’s the bouncer at the door before the second bouncer at the bar.
Full example: Clone the working files at github.com/KingPin/sumguy-examples/security/vaultwarden-behind-authelia
Read This Before You Copy Anything
Most guides on this topic, including the first version of this one, get the path split wrong. Then your Bitwarden Android app throws a login error and you spend an evening reading Caddy logs. So let’s start with what Vaultwarden actually serves.
Vaultwarden mounts exactly seven route prefixes. From src/main.rs:
/ web vault UI and static assets/api vault sync, ciphers, folders, organizations/admin admin panel/identity login, token refresh, prelogin, SSO callback/icons website favicons/events audit log events/notifications WebSocket pushThe one that trips everybody is /identity. That prefix carries /identity/connect/token and /identity/accounts/prelogin, which are the first two requests any client makes, browser or not. A native app cannot follow Authelia’s redirect to the login portal and cannot hold an Authelia session cookie. Put /identity behind a two_factor policy and every mobile client, desktop client, CLI, and browser extension fails to log in. The web vault appears to work because your browser already picked up the Authelia cookie on the way to /.
/attachments/* is the second trap. It looks like a human-facing path, but it’s a client download route (/attachments/<cipher_id>/<file_id>?<token>) with its own signed token in the query string. Mobile clients fetch attachments from there directly. Protect it and attachment downloads break on every device.
So the honest split is smaller than you’d like:
Bypass Authelia (clients need direct access):
/api/*: vault sync and CRUD/identity/*: login, token refresh, prelogin, SSO callback/attachments/*: attachment downloads, already token-authenticated/icons/*: website favicons/events/*: audit log events/notifications/*: WebSocket push/.well-known/*: Apple universal links/alive: health check
Enforce Authelia two-factor:
/: web vault UI/admin: admin panel
That’s it. Two paths.
What This Actually Buys You
Since we’re being honest: ForwardAuth in front of Vaultwarden protects the browser-facing surface, not your vault data. An attacker holding your master password can still authenticate against /identity/connect/token and pull your entire vault through /api/* without ever seeing an Authelia prompt. Any guide claiming Authelia stands between the internet and your ciphers is describing a setup where the mobile apps do not work.
What you do get is worth the hour:
/admingoes from “one leaked token away from disaster” to “one leaked token plus TOTP”.- The web vault login page stops being reachable by anyone who finds your domain, which kills drive-by credential stuffing against the UI.
- Vaultwarden’s own signup and password-reset pages are no longer exposed.
- You get an audit trail of who opened the UI, in Authelia’s logs rather than nowhere.
The API surface still needs defending, just not by Authelia. Vaultwarden’s built-in 2FA and fail2ban cover that, and there’s a section on both below.
The Stack
Four containers. Honest and straightforward.
services: caddy: image: caddy:2.11-alpine container_name: caddy restart: unless-stopped ports: - "80:80" - "443:443" volumes: - ./Caddyfile:/etc/caddy/Caddyfile:ro - caddy_data:/data - caddy_config:/config networks: - proxy
authelia: image: authelia/authelia:4.39 container_name: authelia restart: unless-stopped volumes: - ./authelia/config:/config environment: AUTHELIA_IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET_FILE: /config/secrets/jwt_secret AUTHELIA_SESSION_SECRET_FILE: /config/secrets/session_secret AUTHELIA_STORAGE_ENCRYPTION_KEY_FILE: /config/secrets/storage_encryption_key AUTHELIA_STORAGE_POSTGRES_PASSWORD_FILE: /config/secrets/postgres_password AUTHELIA_NOTIFIER_SMTP_PASSWORD_FILE: /config/secrets/smtp_password networks: - proxy depends_on: - redis - postgres
redis: image: redis:8.10-alpine container_name: authelia_redis restart: unless-stopped networks: - proxy
postgres: image: postgres:18-alpine container_name: authelia_postgres restart: unless-stopped environment: POSTGRES_DB: authelia POSTGRES_USER: authelia POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password volumes: - postgres_data:/var/lib/postgresql/data - ./authelia/config/secrets/postgres_password:/run/secrets/postgres_password:ro networks: - proxy
vaultwarden: image: vaultwarden/server:1.37.2 container_name: vaultwarden restart: unless-stopped environment: DOMAIN: "https://vault.yourdomain.com" SIGNUPS_ALLOWED: "false" INVITATIONS_ALLOWED: "true" IP_HEADER: "X-Forwarded-For" SMTP_HOST: "smtp.yourdomain.com" SMTP_PORT: "587" SMTP_SECURITY: "starttls" SMTP_PASSWORD: "your_smtp_password" ADMIN_TOKEN: "generate_with_argon2" volumes: - vaultwarden_data:/data networks: - proxy
networks: proxy: driver: bridge
volumes: caddy_data: caddy_config: postgres_data: vaultwarden_data:Two things in there deserve a note.
IP_HEADER: "X-Forwarded-For". Vaultwarden defaults to X-Real-IP, and Caddy’s reverse_proxy sets X-Forwarded-For instead. Leave the default and every failed login in your log shows the Caddy container’s IP, so fail2ban bans your own proxy and nothing else. Either set this, or have Caddy set X-Real-IP (the Caddyfile below does both, belt and braces).
No WEBSOCKET_ENABLED. That variable was removed when Vaultwarden moved WebSocket traffic onto the main HTTP port. The current name is ENABLE_WEBSOCKET and it defaults to true, so on 1.37 you set nothing at all. If you copied WEBSOCKET_ENABLED=true from an older guide, it’s been doing nothing for two years.
Generate the Argon2 admin token with:
docker run --rm -it vaultwarden/server:1.37.2 /vaultwarden hash --preset owaspPaste the output hash into ADMIN_TOKEN. Don’t use a plain-text token in 2026.
Authelia Config
Create authelia/config/configuration.yml. This targets Authelia 4.39, which renamed a pile of keys from the 4.37 configs still floating around in old blog posts.
server: address: 'tcp://:9091/'
log: level: 'info'
identity_validation: reset_password: # supplied by AUTHELIA_IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET_FILE jwt_lifespan: '5 minutes'
totp: issuer: 'vault.yourdomain.com' period: 30 skew: 1
authentication_backend: file: path: '/config/users.yml' password: algorithm: 'argon2' argon2: variant: 'argon2id' iterations: 3 memory: 65536 parallelism: 4
access_control: default_policy: 'deny' rules: - domain: 'vault.yourdomain.com' resources: - '^/api($|/.*)' - '^/identity($|/.*)' - '^/attachments($|/.*)' - '^/icons($|/.*)' - '^/events($|/.*)' - '^/notifications($|/.*)' - '^/\.well-known($|/.*)' - '^/alive$' policy: 'bypass' - domain: 'vault.yourdomain.com' policy: 'two_factor'
session: # secret supplied by AUTHELIA_SESSION_SECRET_FILE expiration: '1 hour' inactivity: '5 minutes' remember_me: '1 month' cookies: - name: 'authelia_session' domain: 'yourdomain.com' authelia_url: 'https://auth.yourdomain.com' default_redirection_url: 'https://vault.yourdomain.com' redis: host: 'redis' port: 6379
regulation: modes: - 'user' - 'ip' max_retries: 5 find_time: '2 minutes' ban_time: '10 minutes'
storage: # encryption_key supplied by AUTHELIA_STORAGE_ENCRYPTION_KEY_FILE postgres: address: 'tcp://postgres:5432' database: 'authelia' username: 'authelia'
notifier: smtp: address: 'smtp://smtp.yourdomain.com:587' tls: skip_verify: falseFour details that break startup if you get them wrong:
Secrets are not in this file. Authelia refuses to start if a value is defined both in the config and via a _FILE environment variable. Every secret above is supplied by the env vars in the compose file, and the config just has a comment where the value would go. If you paste a literal jwt_secret: back in, you get a hard error, not a warning.
server.address replaced server.host and server.port. Same for storage.postgres.address and notifier.smtp.address. The old two-key form was removed.
regulation.modes defaults to ['user'] only. Out of the box Authelia locks the account, not the attacker’s IP, so a botnet spraying one username locks your login out and then walks away untouched. Adding 'ip' is what gets you the ten-minute source-IP ban most people assume they already have.
default_policy: 'deny' with a catch-all rule. The last rule has no resources key, so it matches everything on that domain that the bypass rule didn’t. Anything you forget to enumerate gets 2FA rather than getting waved through, which is the direction you want a mistake to fail in.
Users File
Create authelia/config/users.yml. Hash passwords with:
docker run --rm authelia/authelia:4.39 \ authelia crypto hash generate argon2 --password 'YourPasswordHere'users: yourname: displayname: 'Your Name' password: '$argon2id$v=19$m=65536,t=3,p=4$...' groups: - 'admins'Secrets
Create these files in authelia/config/secrets/:
mkdir -p authelia/config/secretscd authelia/config/secretsopenssl rand -base64 64 | tr -d '\n' > jwt_secretopenssl rand -base64 64 | tr -d '\n' > session_secretopenssl rand -base64 64 | tr -d '\n' > storage_encryption_keyopenssl rand -base64 32 | tr -d '\n' > postgres_passwordprintf '%s' 'your_smtp_password' > smtp_passwordchmod 600 ./*The tr -d '\n' matters. openssl rand appends a newline, Authelia reads the file verbatim, and a trailing newline inside a Postgres password produces an authentication failure that the logs describe as a connection problem.
Caddyfile
Here’s where the split lives. Note what this config does not do: it never lists the bypass paths twice.
vault.yourdomain.com { # Paths the Bitwarden clients must reach without an Authelia session. # /identity is the one everybody forgets: it carries connect/token # and accounts/prelogin, the first two calls every client makes. @client_paths { path /api/* /identity/* /attachments/* /icons/* path /events/* /notifications/* /.well-known/* /alive } handle @client_paths { reverse_proxy vaultwarden:80 { # Real client IP for Vaultwarden's log, which fail2ban reads header_up X-Real-IP {remote_host} } }
# Everything else (web vault UI, /admin) goes through Authelia. handle { forward_auth authelia:9091 { uri /api/authz/forward-auth copy_headers Remote-User Remote-Groups Remote-Name Remote-Email } reverse_proxy vaultwarden:80 { header_up X-Real-IP {remote_host} } }
encode zstd gzip tls { protocols tls1.2 tls1.3 }}
# Authelia portal, needs its own subdomain for the redirect to workauth.yourdomain.com { reverse_proxy authelia:9091}handle blocks in Caddy are mutually exclusive and evaluated in written order, so the bare handle at the bottom catches everything the matcher above it didn’t. The older pattern for this used a second @protected matcher repeating every path with not path in front of it. Skip that. Two lists that must stay in sync are how the /identity bug got into circulation: people added it to the bypass matcher and forgot the negated copy, or the reverse.
You also don’t need a separate WebSocket block. Vaultwarden serves the notifications hub on port 80 alongside everything else now, and /notifications/* is already in the bypass matcher, so Caddy proxies the upgrade without special handling.
The forward_auth block sends a subrequest to Authelia. If Authelia returns 200, the request continues to Vaultwarden with the identity headers set. If it returns 401, Caddy redirects the user to the Authelia portal, which handles TOTP enrollment and challenges.
Defending the Bypassed Paths
/identity/connect/token is now facing the internet with only Vaultwarden’s own auth in front of it. Two things close that gap.
Turn on Vaultwarden’s built-in 2FA for every account. This is the layer that actually protects your ciphers, and the article you’re reading does not replace it. Log in to the web vault, go to Settings, Security, Two-step Login, and enable TOTP or a passkey. Authelia guarding / does nothing for an attacker hitting the API directly.
Point fail2ban at the Vaultwarden log. Set LOG_FILE=/data/vaultwarden.log and LOG_LEVEL=warn on the container, then:
[Definition]failregex = ^.*Username or password is incorrect\. Try again\. IP: <ADDR>\. Username:.*$ignoreregex =[vaultwarden]enabled = trueport = 80,443filter = vaultwardenlogpath = /path/to/vaultwarden_data/vaultwarden.logmaxretry = 3bantime = 14400findtime = 14400This is the part that depends on IP_HEADER being right. Tail the log and confirm the IP: field shows a real client address and not 172.x.x.x, otherwise the jail bans your reverse proxy the first time you fat-finger your own password. If you’d rather verify it properly than hope, there’s a whole post on checking that fail2ban is doing anything at all.
Vaultwarden Settings Worth Knowing
SIGNUPS_ALLOWED=false. Disables the public signup page. New users get invited by email instead. You don’t want strangers creating accounts in your password manager.
INVITATIONS_ALLOWED=true. Lets you invite users from the admin panel. Combined with the SMTP config, this sends invite emails with enrollment links.
DOMAIN. Must match your actual URL exactly. Vaultwarden derives attachment URLs, TOTP QR codes, and the SSO callback URL from it.
ENABLE_WEBSOCKET. Defaults to true and serves on the main port. Nothing to configure, and the old WEBSOCKET_ENABLED plus port 3012 arrangement is gone.
Admin token. After setup, navigate to https://vault.yourdomain.com/admin. Authelia prompts for TOTP first, then the Vaultwarden admin panel asks for the token. Two layers, both have to pass. This is the single biggest win in the whole setup.
First Run and TOTP Enrollment
Bring it up:
docker compose up -ddocker compose logs -f autheliaWatch for "Startup complete" in the Authelia logs. Then navigate to https://vault.yourdomain.com and you’ll get bounced to https://auth.yourdomain.com. Log in with your credentials from users.yml. Authelia walks you through TOTP enrollment: scan the QR code, confirm with a code.
After enrollment, opening the web vault or the admin panel needs your Authelia password plus the 6-digit code, before Bitwarden’s own login even renders.
Now test the clients, in this order, because the second test is the one that catches the mistake this article exists to fix:
- Web vault: browse to
https://vault.yourdomain.com. You should hit Authelia’s login page before seeing any Bitwarden UI. - Mobile, from a logged-out state: sign out of the Bitwarden app completely, then log back in. A cached session hides a broken
/identity, so “sync works” on an already-logged-in app proves nothing. If login returns an error here,/identity/*is not in your bypass list. - Attachments: open a vault item with a file attached and download it on mobile. This exercises
/attachments/*. - Admin: browse to
/adminand confirm you get Authelia, then the token prompt.
Backup That Vault
If you lose your Vaultwarden data, you lose all your passwords. Two rules: never cp a live SQLite database, and back up more than the database.
Copying db.sqlite3 while Vaultwarden is writing to it can capture a torn page, and you find out at restore time. Use SQLite’s own backup API instead, which takes a consistent snapshot of a database in use.
#!/usr/bin/env bashset -euo pipefail
BACKUP_DIR="/mnt/offsite/vaultwarden-backups"DATE=$(date +%Y%m%d-%H%M%S)DATA_DIR="/path/to/vaultwarden_data"DEST="$BACKUP_DIR/$DATE"
mkdir -p "$DEST"
# Consistent snapshot of a live databasesqlite3 "$DATA_DIR/db.sqlite3" ".backup '$DEST/db.sqlite3'"
# rsa_key signs every session token. Lose it and everyone is logged out.cp "$DATA_DIR/rsa_key.pem" "$DEST/" 2>/dev/null || truecp "$DATA_DIR/config.json" "$DEST/" 2>/dev/null || true
rsync -a "$DATA_DIR/attachments/" "$DEST/attachments/"rsync -a "$DATA_DIR/sends/" "$DEST/sends/"
# Authelia holds the TOTP secrets for enrolled usersdocker exec authelia_postgres pg_dump -U authelia authelia \ | gzip > "$DEST/authelia.sql.gz"
# Keep 30 daysfind "$BACKUP_DIR" -mindepth 1 -maxdepth 1 -type d -mtime +30 -exec rm -rf {} +
echo "Backup complete: $DATE"Add to crontab:
0 2 * * * /opt/scripts/backup-vaultwarden.sh >> /var/log/vaultwarden-backup.log 2>&12 AM every day. Your 2 AM self will appreciate not having to explain to yourself why you didn’t set this up.
That pg_dump line is not optional. Authelia’s Postgres database holds the TOTP secrets for every enrolled user. Restore Vaultwarden without it and nobody can get past the Authelia portal to reach the vault you just restored.
SSO via OIDC (The Fancy Option)
Authelia can act as an OIDC identity provider, and Vaultwarden has supported SSO login in mainline since 1.35.0 (December 2025). No paid tier, no forks. If you’re on anything older, this section does not apply to you, and you want at least 1.36.0 anyway because it carried a fix for an SSO login CSRF issue.
The callback URL is derived from DOMAIN and lands at https://vault.yourdomain.com/identity/connect/oidc-signin. Read that path again. SSO is a second, independent reason /identity has to bypass Authelia’s ForwardAuth: if it doesn’t, the OIDC callback gets intercepted by the login portal and the flow dies at the redirect.
Add an OIDC client to Authelia’s configuration.yml:
identity_providers: oidc: # hmac_secret supplied by AUTHELIA_IDENTITY_PROVIDERS_OIDC_HMAC_SECRET_FILE jwks: - key_id: 'main' algorithm: 'RS256' use: 'sig' key: | -----BEGIN PRIVATE KEY----- ... -----END PRIVATE KEY----- clients: - client_id: 'vaultwarden' client_name: 'Vaultwarden' client_secret: '$pbkdf2-sha512$310000$...' public: false authorization_policy: 'two_factor' require_pkce: true pkce_challenge_method: 'S256' grant_types: - 'authorization_code' - 'refresh_token' response_types: - 'code' redirect_uris: - 'https://vault.yourdomain.com/identity/connect/oidc-signin' scopes: - 'openid' - 'profile' - 'email' - 'offline_access' userinfo_signed_response_alg: 'none'The client keys are client_id and client_secret, not id and secret, and client_secret wants a hashed digest rather than the plain string. Generate both with:
docker run --rm authelia/authelia:4.39 authelia crypto rand --length 72 --charset rfc3986docker run --rm authelia/authelia:4.39 authelia crypto hash generate pbkdf2 \ --variant sha512 --password 'the_value_from_above'Generate the JWKS key with authelia crypto pair rsa generate. The old issuer_private_key single-key option was replaced by the jwks list.
Then set these on Vaultwarden:
SSO_ENABLED=trueSSO_ONLY=falseSSO_AUTHORITY=https://auth.yourdomain.comSSO_CLIENT_ID=vaultwardenSSO_CLIENT_SECRET=the_plain_value_not_the_hashSSO_SCOPES="openid profile email offline_access"SSO_PKCE=trueoffline_access is required with Authelia. Without it you get no refresh token, and sessions expire the moment the access token does. Note that Authelia will not accept that scope unless the client also declares response_types: ['code'] and the refresh_token grant, which is why both are spelled out above. Leave them off and validate-config warns today and fails in a later release. SSO_AUTHORITY must match the issuer field returned by https://auth.yourdomain.com/.well-known/openid-configuration exactly, trailing slash included or excluded as that document says.
With SSO_ONLY=false you keep the normal login as a fallback, which you want the first time you set this up.
For a single-user or small-family setup, the ForwardAuth approach above is simpler and gets you most of the benefit. OIDC earns its complexity when you’re already running Authelia for six other services and want one identity across all of them. If you’re still choosing an identity provider, Authentik vs Authelia covers that decision.
The Bottom Line
Vaultwarden’s built-in 2FA protects your vault data. Authelia at the proxy layer protects the web UI and the admin panel. You want both, and you should be clear-eyed about which one is doing which job.
The setup in this article gets you:
/adminbehind Authelia TOTP, so a leaked admin token isn’t game over- The web vault UI unreachable without an Authelia login, which stops credential stuffing against the browser flow
- Bitwarden mobile, desktop, CLI, and extension clients working normally, because the eight client paths bypass
- Source-IP brute force regulation on the Authelia layer, five strikes and a ten-minute ban, once you set
modes: ['user', 'ip'] - fail2ban covering the API and login paths that Authelia deliberately does not touch
What it does not get you is Authelia standing between the internet and your ciphers. That door stays open by design, because closing it closes it on your phone too. Your master password and Vaultwarden’s own two-step login are what guard the vault contents. Authelia guards the doors a browser walks through.
Run it. Back it up. Then sign out of the mobile app and sign back in, because that is the one test that tells you whether you got the bypass list right.
Common Questions
Why does the Bitwarden Android app fail to log in behind Authelia?
Because /identity is in your protected paths. The app calls /identity/accounts/prelogin and /identity/connect/token before anything else, and it cannot complete Authelia’s browser-based 2FA challenge. Add ^/identity($|/.*) to the bypass rule in Authelia and /identity/* to the bypass matcher in your proxy config.
Does Authelia protect my actual passwords from someone with my master password?
No. The Bitwarden API paths bypass Authelia so the mobile apps work, so anyone holding your master password can authenticate at /identity/connect/token and sync your whole vault without seeing an Authelia prompt. Vaultwarden’s own two-step login is what stops that. Authelia protects the web UI and /admin.
Do I still need Vaultwarden’s built-in 2FA if I run Authelia?
Yes, and more than before. Authelia covers only two paths, / and /admin. Every API and login path bypasses it. Vaultwarden’s two-step login is the only 2FA an attacker hitting the API directly ever encounters, so turn it on for every account.
What Vaultwarden version do I need for OIDC SSO?
Vaultwarden 1.35.0, released December 2025, merged SSO into mainline. Use 1.36.0 or newer, which fixed an SSO login CSRF issue, and 1.37.0 fixed a cookie path bug. Older versions need the Timshel fork. The callback lands on /identity/connect/oidc-signin, so /identity must bypass forward auth for SSO to work.
Can I put Authelia in front of Vaultwarden with Nginx Proxy Manager instead of Caddy?
Yes, but the path split is harder to express. NPM’s per-location advanced config means writing the bypass list by hand in raw Nginx, with no equivalent of Caddy’s mutually exclusive handle blocks. Traefik handles it cleanly with router priorities. Caddy or Traefik will cost you less time here.
Related Reading
- Vaultwarden Organization Sharing: Password Management for Your Whole Household (or Team)
- Vaultwarden vs Bitwarden: Own Your Passwords Before Someone Else Does
- Authentik vs Authelia: SSO for Your Self-Hosted Stack
- Caddyfile Patterns That Actually Work
- Is fail2ban Actually Working? Here’s How to Check