Skip to content
Go back

Vaultwarden Behind Authelia: 2FA That Holds

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

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. Here’s the thing though — 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 simple admin token, no 2FA by default. The signup page? Wide open if you forgot to flip SIGNUPS_ALLOWED=false. And the mobile clients, when they sync, hit /api/sync with your master password token directly — no 2FA prompt in sight once the initial login is cached.

None of that is a bug. It’s just how Bitwarden’s architecture works. But if you’re running this thing exposed to the internet, you want a second layer before any of that is reachable. That’s where Authelia comes in.

Authelia is an open-source authentication and authorization proxy. You put it in front of your app via ForwardAuth, and it enforces 2FA — TOTP, WebAuthn, push notifications — before the request ever reaches Vaultwarden. It’s the bouncer at the door before the second bouncer at the bar.


The Architecture (Two Zones, One Rule)

Here’s the mental model: your traffic flows like this:

Client → Caddy → Authelia ForwardAuth → Vaultwarden

But not all traffic can go through Authelia. The Bitwarden mobile and desktop apps communicate constantly with specific API endpoints — syncing vault data, fetching icons, handling websocket push notifications for live updates. If you force those through Authelia’s interactive 2FA challenge, your clients break. They can’t complete a browser-based TOTP prompt.

So we split into two zones:

Bypass Authelia (clients need direct access):

Enforce Authelia two-factor:

The clients authenticate with Bitwarden’s own session tokens at the API layer. The web vault and admin panel get Authelia in front because those are human-facing interactive sessions.


The Stack

Four containers. Honest and straightforward.

services:
caddy:
image: caddy:2.8-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.38
container_name: authelia
restart: unless-stopped
volumes:
- ./authelia/config:/config
environment:
- AUTHELIA_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
networks:
- proxy
depends_on:
- redis
- postgres
redis:
image: redis:7.2-alpine
container_name: authelia_redis
restart: unless-stopped
networks:
- proxy
postgres:
image: postgres:16-alpine
container_name: authelia_postgres
restart: unless-stopped
environment:
POSTGRES_DB: authelia
POSTGRES_USER: authelia
POSTGRES_PASSWORD: changeme_strong_password
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- proxy
vaultwarden:
image: vaultwarden/server:1.32.0
container_name: vaultwarden
restart: unless-stopped
environment:
DOMAIN: "https://vault.yourdomain.com"
SIGNUPS_ALLOWED: "false"
INVITATIONS_ALLOWED: "true"
WEBSOCKET_ENABLED: "true"
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:

Generate the Argon2 admin token with:

Terminal window
docker run --rm -it vaultwarden/server:1.32.0 /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 is the main config — real values, real structure:

server:
host: 0.0.0.0
port: 9091
log:
level: info
jwt_secret: "{{ secret from /config/secrets/jwt_secret }}"
default_redirection_url: "https://vault.yourdomain.com"
totp:
issuer: vault.yourdomain.com
period: 30
skew: 1
authentication_backend:
file:
path: /config/users.yml
password:
algorithm: bcrypt
iterations: 12
access_control:
default_policy: two_factor
rules:
- domain: vault.yourdomain.com
resources:
- "^/api($|/.*)"
- "^/icons($|/.*)"
- "^/events($|/.*)"
- "^/notifications($|/.*)"
policy: bypass
- domain: vault.yourdomain.com
resources:
- "^/admin($|/.*)"
- "^/$"
- "^/attachments($|/.*)"
- "^/identity($|/.*)"
policy: two_factor
session:
name: authelia_session
secret: "{{ secret from /config/secrets/session_secret }}"
expiration: 3600
inactivity: 300
remember_me_duration: 1M
redis:
host: redis
port: 6379
regulation:
max_retries: 5
find_time: 2m
ban_time: 10m
storage:
encryption_key: "{{ secret from /config/secrets/storage_encryption_key }}"
postgres:
host: postgres
port: 5432
database: authelia
username: authelia
password: changeme_strong_password
notifier:
smtp:
password: your_smtp_password
host: smtp.yourdomain.com
port: 587
sender: "Authelia <[email protected]>"
tls:
skip_verify: false

The regulation block is important. Five failed attempts within two minutes and Authelia bans the source IP for ten minutes. This kills brute force before it gets anywhere interesting.

Users File

Create authelia/config/users.yml. Hash passwords with:

Terminal window
docker run --rm authelia/authelia:4.38 authelia crypto hash generate bcrypt --password 'YourPasswordHere'
users:
yourname:
displayname: "Your Name"
password: "$2b$12$hashedpasswordhere"
groups:
- admins

Secrets

Create these files in authelia/config/secrets/:

Terminal window
mkdir -p authelia/config/secrets
openssl rand -base64 64 > authelia/config/secrets/jwt_secret
openssl rand -base64 64 > authelia/config/secrets/session_secret
openssl rand -base64 64 > authelia/config/secrets/storage_encryption_key
chmod 600 authelia/config/secrets/*

Caddyfile

Here’s where the two-zone split actually lives. Pay attention to the ordering — Caddy evaluates matchers top to bottom, and you want the bypass paths handled before the ForwardAuth check fires.

vault.yourdomain.com {
# Vaultwarden WebSocket
@websocket {
path /notifications/hub
header Connection *Upgrade*
header Upgrade websocket
}
handle @websocket {
reverse_proxy vaultwarden:80
}
# Bypass Authelia for Bitwarden client API paths
@bypass_authelia {
path /api/*
path /icons/*
path /events/*
path /notifications/*
}
handle @bypass_authelia {
reverse_proxy vaultwarden:80
}
# Everything else goes through Authelia forward auth
@protected {
not path /api/*
not path /icons/*
not path /events/*
not path /notifications/*
}
handle @protected {
forward_auth authelia:9091 {
uri /api/verify?rd=https://vault.yourdomain.com
copy_headers Remote-User Remote-Groups Remote-Name Remote-Email
}
reverse_proxy vaultwarden:80
}
encode 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
}

The forward_auth block sends a subrequest to Authelia. If Authelia returns 200, the request continues to Vaultwarden with headers set. If it returns 401, Caddy redirects the user to the Authelia portal to authenticate. That portal handles TOTP enrollment, challenges, and everything else.


Vaultwarden Settings Worth Knowing

A few env vars that matter here:

SIGNUPS_ALLOWED=false — Disables the public signup page entirely. New users get invited via email instead. You don’t want random people creating accounts in your password manager.

INVITATIONS_ALLOWED=true — Lets you invite users from the admin panel. Combined with SMTP config, this sends invite emails with enrollment links.

WEBSOCKET_ENABLED=true — Enables real-time vault sync across devices. When you add a password on your phone, it shows up in your browser within seconds instead of on next manual sync. The notifications websocket path is already in the bypass list above.

DOMAIN — Must match your actual URL exactly. Vaultwarden uses this for generating correct attachment URLs and TOTP QR codes.

Admin token — After setup, navigate to https://vault.yourdomain.com/admin. Authelia will prompt you for TOTP first. Then the Vaultwarden admin panel. Two layers, both have to pass.


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 — you’ll get bounced to https://auth.yourdomain.com. Log in with your credentials from users.yml. Authelia will walk you through TOTP enrollment: scan the QR code with your authenticator app, confirm with a code.

After enrollment, future logins to the protected paths require your password plus the 6-digit TOTP code. The Bitwarden mobile client won’t see any of this — it talks directly to /api/* with its own session token, completely unaffected.

Test mobile sync: open the Bitwarden app, force a manual sync. It should complete without any Authelia prompts. Test the web vault: navigate to https://vault.yourdomain.com in a browser. You should hit Authelia’s login page before ever seeing Bitwarden’s UI.


Backup That Vault

If you lose your Vaultwarden data, you lose all your passwords. Here’s a simple nightly backup:

/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/_data"
mkdir -p "$BACKUP_DIR"
# SQLite database
cp "$DATA_DIR/db.sqlite3" "$BACKUP_DIR/db-$DATE.sqlite3"
# Attachments
rsync -a "$DATA_DIR/attachments/" "$BACKUP_DIR/attachments-$DATE/"
# Keep 30 days
find "$BACKUP_DIR" -name "db-*.sqlite3" -mtime +30 -delete
find "$BACKUP_DIR" -name "attachments-*" -maxdepth 1 -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.

Authelia’s Postgres data is worth backing up too — it holds your TOTP secrets for enrolled users. If you lose that, everyone has to re-enroll.


SSO via OIDC (The Fancy Option)

Authelia can act as an OIDC identity provider. Vaultwarden supports SSO login as a built-in feature (no paid tier required, despite what you might think — the self-hosted version has it). This means instead of the Bitwarden password + master password flow, users log in via Authelia SSO.

The short version: add an OIDC client config to Authelia’s configuration.yml:

identity_providers:
oidc:
hmac_secret: "another_random_secret"
issuer_private_key: |
-----BEGIN RSA PRIVATE KEY-----
...
clients:
- id: vaultwarden
description: Vaultwarden
secret: "client_secret_here"
public: false
authorization_policy: two_factor
redirect_uris:
- https://vault.yourdomain.com/identity/connect/oidc-signin
scopes:
- openid
- profile
- email
userinfo_signing_algorithm: none

Then set these env vars on Vaultwarden:

Terminal window
SSO_ENABLED=true
SSO_ONLY=false
SSO_AUTHORITY=https://auth.yourdomain.com
SSO_CLIENT_ID=vaultwarden
SSO_CLIENT_SECRET=client_secret_here

With SSO_ONLY=false you keep the normal login as a fallback. The ForwardAuth layer is still useful even with OIDC — it catches direct URL access before OIDC kicks in for the API flow.

Honestly, for a single-user or small-family setup, the ForwardAuth approach in this article is simpler. OIDC makes more sense when you’re running Authelia for multiple services and want unified identity management.


The Bottom Line

Vaultwarden’s built-in 2FA protects the application layer. Authelia at the proxy layer protects the surface. You want both.

The setup in this article gets you:

The weak point is still your Vaultwarden master password — that’s encrypted client-side and Authelia never touches it. But now someone would have to compromise your Authelia TOTP and your Bitwarden master password to get in. That’s two separate secrets on two separate devices. Good luck to them.

Run it. Back it up. Test the mobile sync. Then stop thinking about it until you need a password at 2 AM and you’re glad it’s actually there.


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.


Next Post
ddrescue vs TestDisk vs PhotoRec

Discussion

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

Related Posts