Skip to content
Go back

Vaultwarden Behind Authelia: 2FA That Holds

By SumGuy 18 min read
Vaultwarden Behind Authelia: 2FA That Holds
Contents

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 push

The 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):

Enforce Authelia two-factor:

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:

  1. /admin goes from “one leaked token away from disaster” to “one leaked token plus TOTP”.
  2. The web vault login page stops being reachable by anyone who finds your domain, which kills drive-by credential stuffing against the UI.
  3. Vaultwarden’s own signup and password-reset pages are no longer exposed.
  4. 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.

compose.yaml
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_FROM: "[email protected]"
SMTP_PORT: "587"
SMTP_SECURITY: "starttls"
SMTP_USERNAME: "[email protected]"
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:

Terminal window
docker run --rm -it vaultwarden/server:1.37.2 /vaultwarden hash --preset owasp

Paste 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.

authelia/config/configuration.yml
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'
username: '[email protected]'
sender: 'Authelia <[email protected]>'
tls:
skip_verify: false

Four 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:

Terminal window
docker run --rm authelia/authelia:4.39 \
authelia crypto hash generate argon2 --password 'YourPasswordHere'
authelia/config/users.yml
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/:

Terminal window
mkdir -p authelia/config/secrets
cd authelia/config/secrets
openssl rand -base64 64 | tr -d '\n' > jwt_secret
openssl rand -base64 64 | tr -d '\n' > session_secret
openssl rand -base64 64 | tr -d '\n' > storage_encryption_key
openssl rand -base64 32 | tr -d '\n' > postgres_password
printf '%s' 'your_smtp_password' > smtp_password
chmod 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.

Caddyfile
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 work
auth.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:

/etc/fail2ban/filter.d/vaultwarden.conf
[Definition]
failregex = ^.*Username or password is incorrect\. Try again\. IP: <ADDR>\. Username:.*$
ignoreregex =
/etc/fail2ban/jail.d/vaultwarden.local
[vaultwarden]
enabled = true
port = 80,443
filter = vaultwarden
logpath = /path/to/vaultwarden_data/vaultwarden.log
maxretry = 3
bantime = 14400
findtime = 14400

This 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:

Terminal window
docker compose up -d
docker compose logs -f authelia

Watch 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:

  1. Web vault: browse to https://vault.yourdomain.com. You should hit Authelia’s login page before seeing any Bitwarden UI.
  2. 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.
  3. Attachments: open a vault item with a file attached and download it on mobile. This exercises /attachments/*.
  4. Admin: browse to /admin and 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.

/opt/scripts/backup-vaultwarden.sh
#!/usr/bin/env bash
set -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 database
sqlite3 "$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 || true
cp "$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 users
docker exec authelia_postgres pg_dump -U authelia authelia \
| gzip > "$DEST/authelia.sql.gz"
# Keep 30 days
find "$BACKUP_DIR" -mindepth 1 -maxdepth 1 -type d -mtime +30 -exec rm -rf {} +
echo "Backup complete: $DATE"

Add to crontab:

Terminal window
0 2 * * * /opt/scripts/backup-vaultwarden.sh >> /var/log/vaultwarden-backup.log 2>&1

2 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:

authelia/config/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:

Terminal window
docker run --rm authelia/authelia:4.39 authelia crypto rand --length 72 --charset rfc3986
docker 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:

Terminal window
SSO_ENABLED=true
SSO_ONLY=false
SSO_AUTHORITY=https://auth.yourdomain.com
SSO_CLIENT_ID=vaultwarden
SSO_CLIENT_SECRET=the_plain_value_not_the_hash
SSO_SCOPES="openid profile email offline_access"
SSO_PKCE=true

offline_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:

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.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it'll show up above once verified.


Previous Post
Local Coding Agents Need Less Context
Next Post
LM Studio vs Jan vs GPT4All: Desktop LLM Clients

Discussion

Powered by Garrul . Sign in with GitHub or Google, or post anonymously.

Related Posts