Vaultwarden Secure Na? Hardening Weh You Must Do
Vaultwarden dey encrypt vault items for client, so server no see plaintext. But admin token and backup file na the real risks wey you must lock down.
Vaultwarden secure na? The short answer
Vaultwarden secure for the place wey matter pass, because every vault item dey encrypted for your device before e reach server. Server dey store blobs wey e no fit read. Person wey copy the complete database still need master password before e fit get anything useful from am.
That answer dey cover plenty things, and na the things wey you configure fit cause problem. Admin panel wey token anybody fit guess dey behind am. Container port wey you publish to the whole internet. Plaintext config.json. Backup tarball wey dey sit for home directory for the same box. None of these na cryptography problem. All of dem na why self-hosted vaults dey get emptied.
Everything for here assume say install dey work. If you never get one, set am up with Vaultwarden install guide for VPS first, then come back and follow this list from top to bottom.
Wetin server dey actually store
Vaultwarden dey use Bitwarden data model. Vault item name, username, password, notes, and URIs dey encrypted with key wey client derive from your master password, before client send any request. Attachment file contents dey encrypted the same way. Server dey receive opaque data wey UUID (universally unique identifier) attach to am.
Some things no be ciphertext, and you suppose know exactly which ones:
- Your account email address, for plaintext.
- Your KDF (key derivation function) settings and salt, because client need dem to rebuild the key when you login next time.
- Server-side hash of the master password hash wey client send, to authenticate the login itself.
- Metadata: organisation membership, device names, last login times.
- Secret for the two-factor method wey protect Vaultwarden login. E dey inside
twofactortable unencrypted, because server need calculate the expected code and compare am with your own. This no be the same thing as TOTP (time-based one-time password) secret wey you store inside vault item, wey dey encrypted like every other field.
Data folder small. For Docker install, na anything wey you mount at /data.
sudo ls -l /vw-data/db.sqlite3 hold almost all state. attachments/ hold uploaded files, one file for each UUID, and na the only important type of data wey no dey inside database tables. sends/ hold Send attachments and dem suppose be temporary. icon_cache/ na throwaway. rsa_key.pem and the other related files dey sign JWTs (JSON web tokens) for users wey don login, so anybody wey get copy of that private key fit forge vault login session. config.json only dey exist after you enable admin page, and the project talk am clearly: e hold admin token and your SMTP credentials for plaintext.
So, the practical threat model na filesystem access, no be network cryptography. Read access to that one directory go hand over every user email address, their login 2FA secrets, key wey fit forge sessions, and offline copy of every vault wey person fit attack at leisure. Every step for here dey to keep people comot from that directory.
Fix admin token first
/admin na full control panel: user list, invitations, deletion, and every runtime setting. One shared secret dey protect am, and nothing else. No username. No per-user two-factor.
Older guides tell you to generate ADMIN_TOKEN with openssl rand -base64 48. E dey work, and e writes the secret as plaintext inside config.json and your compose file. Vaultwarden also accepts Argon2 PHC (password hashing competition) string, so the stored value go be hash instead. Generate one against a running container:
docker exec -it vaultwarden /vaultwarden hashOr without touching the running container at all:
docker run --rm -it vaultwarden/server /vaultwarden hashE go ask for password two times, then print one line wey start with $argon2id$. For bare-metal install, run ./vaultwarden hash. If you prefer to use argon2 CLI directly, the project documents the OWASP minimum parameters:
echo -n 'MySecretPassword' | argon2 "$(openssl rand -base64 32)" -e -id -k 19456 -t 2 -p 1Now na the trap wey fit waste one hour. PHC string full of $ characters, and Docker Compose treats $ as variable interpolation. If you paste am unescaped inside environment: block, the value wey reach the container go spoil, so /admin go reject token wey you know say correct. Two safe ways dey. For docker-compose.yml, double every $:
environment:
ADMIN_TOKEN: $$argon2id$$v=19$$m=19456,t=2,p=1$$UUZxK1FZMkZoRHFQRlVrTXZvS0E3bHpNQW55c2dBN2NORzdsa0Nxd1JhND0$$cUoId+JBUsJutlG4rfDZayExfjq4TCt48aBc9qsc3UIFor .env file, you no need escaping, but use single quotes:
ADMIN_TOKEN='$argon2id$v=19$m=65540,t=3,p=4$MmeKRnGK5RW5mJS7h3TOL89GrpLPXJPAtTK8FTqj9HM$DqsstvoSAETl9YhnsXbf43WeaUwJC6JhViIvuPoig78'Then rate-limit the panel and make its session shorter:
ADMIN_RATELIMIT_SECONDS=300
ADMIN_RATELIMIT_MAX_BURST=3
ADMIN_SESSION_LIFETIME=20Three bad attempts inside five minutes and the panel go stop answering that client. The admin session go expire after 20 minutes without activity.
Better pass all these: turn the page off. Most instances need am once, to configure SMTP and invite the first users, and dem no need am again. To disable am, set neither ADMIN_TOKEN nor DISABLE_ADMIN_TOKEN, remove any "admin_token" key from config.json, then recreate the container. E matter to delete the key from the file because the admin page writes settings there, and the value inside config.json overrides the environment. If you only remove the variable, the page go remain open.
Close registration before anybody discover the domain
SIGNUPS_ALLOWED=false
SIGNUPS_VERIFY=true
SHOW_PASSWORD_HINT=falseSIGNUPS_ALLOWED defaults to true. Leave am like that and anybody wey reach your domain fit create account, and their data go dey inside the same db.sqlite3 as your own. Set am to false and add people through invitations from the admin page. This one need working SMTP. INVITATIONS_ALLOWED too dey true by default and allow organisation owners invite other people. This one dey okay when you trust your users, and e suppose dey false for instance wey na only one person dey use. If na only certain domains suppose ever register, SIGNUPS_DOMAINS_WHITELIST=example.com dey narrower than open signup and e weak well-well compared with invitations.
SHOW_PASSWORD_HINT dey false by default and e suppose remain like that. When person type valid email address for login form, with am enabled, the form go return that account master password hint. This one expose the hint and confirm say the address dey exist.
If your instance run with signup open for any length of time, open the admin page and check the user list before you assume say na only your account dey there.
Port wey you no mean publish
The Docker image dey listen for port 80 inside the container. Bare-metal install dey use ROCKET_PORT=8000 by default. The documented run command dey publish am like this:
--publish 127.0.0.1:8000:80The 127.0.0.1: prefix na the main point. If you write -p 8000:80 instead, Docker go bind 0.0.0.0. E go do this by writing DNAT (destination network address translation) rules inside the nat table. Dem rules dey evaluate before the filter chains wey ufw dey manage. So ufw status go report say the port dey denied, but the port still dey answer requests from internet. You fit read the complete mechanism for guide about Docker ports wey bypass ufw.
Check wetin really dey listen:
sudo ss -tlnp | grep 8000Correct result na one line wey bind to 127.0.0.1:8000. If line bind to 0.0.0.0:8000, e mean say the vault dey exposed directly. Correct the mapping, then recreate the container, because Docker fix port binding when e create the container, and docker compose restart no go change am:
docker compose up -d --force-recreateOne more port dey show for old guides: 3012, the separate WebSocket port. Vaultwarden remove support for am for version 1.31.0, because notification traffic don move go the main HTTP port. WEBSOCKET_ENABLED and WEBSOCKET_PORT don dey ignored since version 1.29.0. The current switch na ENABLE_WEBSOCKET, and e dey use true by default. If your firewall or compose file still dey open 3012, close am.
End TLS for reverse proxy, no be for Rocket
Vaultwarden fit serve TLS (transport layer security) by itself through Rocket, wey be the web framework, but the project advise say make you no do am for production. Rocket built-in TLS no get strict SNI (server name indication) support. Na this one also make the hardening advice tell you say make you access your instance with hostname, never with bare IP address. People dey scan public IP ranges all the time. Vault wey dey answer for IP address na vault wey dem fit find.
Na these parts for nginx server block dey important:
client_max_body_size 525M;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}nginx default client_max_body_size na 1 MB. So if that line no dey, attachment upload go fail with 413 Request Entity Too Large for nginx error log, while Vaultwarden no go log anything. The Upgrade and Connection headers carry WebSocket handshake go /notifications/hub. If you remove dem, vault go still work, but changes no go dey show for your other devices until you reload the page by hand.
Caddy configuration shorter, and e dey obtain certificate by itself:
vw.example.com {
reverse_proxy 127.0.0.1:8000 {
header_up X-Real-IP {remote_host}
}
}Then tell Vaultwarden about am:
DOMAIN=https://vw.example.com
IP_HEADER=X-Real-IPIP_HEADER default value already na X-Real-IP. So you just need make sure say proxy really dey set that header. If e no set am, every log line and every login rate limit go see 127.0.0.1, wey be the proxy itself. That means failures from one attacker go count against every user for the instance. Set DOMAIN to the real https URL too, because Vaultwarden dey use am build invitation and password reset links. WebAuthn security keys dey bind to that origin.
One detail wey people dey miss: WebSocket connection dey pass session token for query string, as /notifications/hub?access_token=[JWT]. That token go enter your proxy access log as plain text. Redact the access_token parameter for the log format, or make sure say dem no ship those logs go anywhere wey you no control.
Ban brute force for login endpoint
Rate limits dey on by default (LOGIN_RATELIMIT_SECONDS=60, LOGIN_RATELIMIT_MAX_BURST=10). Dem slow attacker down. But dem no stop am. fail2ban fit stop am, but Vaultwarden first need write log file, and e no dey do that by default:
LOG_FILE=/data/vaultwarden.log
LOG_LEVEL=info
EXTENDED_LOGGING=trueFailed login go then produce exactly one line, and na this string your filter must match:
[2026-08-07 09:14:22][vaultwarden::api::identity][ERROR] Username or password is incorrect. Try again. IP: 203.0.113.10. Username: user@example.com.Write the filter to /etc/fail2ban/filter.d/vaultwarden.local:
[INCLUDES]
before = common.conf
[Definition]
failregex = ^.*?Username or password is incorrect\. Try again\. IP: <ADDR>\. Username:.*$
ignoreregex =And write the jail to /etc/fail2ban/jail.d/vaultwarden.local:
[vaultwarden]
enabled = true
port = 80,443
filter = vaultwarden
banaction = %(banaction_allports)s
logpath = /vw-data/vaultwarden.log
maxretry = 3
bantime = 14400
findtime = 14400If you keep the admin page, add another jail wey get failregex as ^.*Invalid admin token\. IP: <ADDR>.*$, because admin failures dey log with different message and the login filter no go ever see dem. Then check your work:
sudo systemctl restart fail2ban
sudo fail2ban-client status vaultwardenWorking jail go list your log file under File list and report Currently failed: 0. Type wrong password three times from another network, and that counter go rise. The address go then appear under Banned IP list. If the counter never move, the usual cause na logpath: e must be the file path for the host, no be the /data/... path inside the container. The second usual cause na missing X-Real-IP, wey make every ban target your own proxy. The remaining setup, including the SSH jail wey you suppose don dey run already, dey for fail2ban guide for Ubuntu 24.04.
Master password still dey control the whole system
Client-side encryption mean say master password na the key. Short master password for an instance wey attacker don copy the database no get protection from anything for this post, because dem fit attack that copy offline at any speed wey their hardware allow. No server setting fit reach attacker own machine.
PASSWORD_ITERATIONS=600000 na the KDF iteration count wey clients receive when dem create new account. Existing accounts keep the value wey dem use when dem create am, so if you increase am, e no change anything for users wey sign up last year. Dem must change am by themselves for the web vault security settings, and this one go re-encrypt their key. Tell dem, because nothing for the interface go tell dem.
Then enable two-factor authentication for each account. E no protect the ciphertext, because vault key come from master password alone. But e stop stolen password from being enough to log in and sync a copy. REQUIRE_DEVICE_EMAIL=true add email confirmation step the first time account log in from device wey system no recognise.
Backups na where self-hosted vaults fit spoil
One tar czf of the data folder, wey dey inside home directory for the same VPS, go cancel every step wey dey above. That archive get db.sqlite3 with every user's ciphertext, rsa_key.pem wey fit forge login sessions, and config.json wey get the admin token and SMTP password as plain text. If person get read access to that one file, e get read access to the vault.
Two rules cover am. Move the archive comot from the box. Encrypt am before e leave.
Correctness na another problem. If you copy db.sqlite3 with cp while the service still dey run, you fit create file wey still dey mid-write and no go open. You no go discover am until restore time. Use SQLite own snapshot instead:
sqlite3 /vw-data/db.sqlite3 ".backup '/tmp/vw-db-backup.sqlite3'"The restore side, wey na the part wey almost nobody dey test, dey covered for Vaultwarden backup and restore guide.
Wetin you dey give up compared with hosted Bitwarden
Make we talk true. People wey full-time work na to operate Bitwarden dey run the hosted service, with published audits from third party and person wey dey on call for 3am. If you self-host, na your own patch schedule go replace all that.
Vaultwarden dey release security fixes as normal releases. Version 1.37.0, wey dem release 24 July 2026, na the current one as of August 2026, and the release notes tell users make dem update as soon as possible. Instance wey you set up one year ago and forget about fit still dey run code wey don old for one year. The latest tag no fit help by itself: container wey dey run go keep the image wey e start with until you run docker compose pull and recreate am. Set up unattended upgrades for Ubuntu for the host packages, and put container update for calendar reminder wey you go actually read.
The conclusion wey honest reader suppose reach be say: na Bitwarden's design dey provide the cryptography, and e solid, but operational risk don move entirely go you. If you patch am and back am up for another location, Vaultwarden instance for VPS wey you control na reasonable place to keep your passwords. If you no go do those two things, pay for the hosted service and use your attention for another matter. The feature-by-feature comparison dey for Vaultwarden compared with self-hosted Bitwarden.
Harden the host underneath the container
Vaultwarden na one process for Linux box, and root for that box fit read /vw-data no matter wetin you configure for the application. Run the container with unprivileged user by using user: "1000:1000" for your compose file, and make sure say data folder ownership match am. Mount anything wey the container no write to as read-only with :ro. Then close the front door: SSH hardening for VPS cover login with key only and disabling password authentication. Na this one stop the basic attack wey fit pass everything wey we don do above.
FAQ
Person fit read my passwords if dem steal the Vaultwarden database?
No be directly. Every vault item dey encrypted for the client with key wey come from the master password, so db.sqlite3 contain ciphertext. Wetin dem get immediately na each account email address, the KDF settings, login and device metadata, and the two-factor secrets for the twofactor table. Dem dey stored unencrypted because the server must calculate the expected code. Dem fit also attack the vault ciphertext offline for as long as dem like. Na why master password length be the number wey decide the outcome.
I suppose use ADMIN_TOKEN or disable the admin page completely?
Disable am if you fit, because most instances need am once to configure SMTP and invite users, then dem no need am again. To disable am, no set ADMIN_TOKEN or DISABLE_ADMIN_TOKEN, remove any "admin_token" key from config.json, then recreate the container. Removing only the environment variable no enough, because settings wey the admin page write dey inside config.json and dem get priority. If you keep the page, store the token as Argon2 hash wey vaultwarden hash produce instead of plaintext random string, and set ADMIN_RATELIMIT_MAX_BURST=3.
My ADMIN_TOKEN correct, but /admin dey reject am. Wetin wrong?
Almost every time, na $ interpolation. An Argon2 PHC string get several $ characters. Docker Compose dey expand dem as variables inside a docker-compose.yml environment: block, so the container receive value wey don spoil even though your file look correct. Double every $ to $$ for the compose file, or move the value enter an .env file wey single quotes wrap. For that place, you no need escaping. Recreate the container afterwards, because restart no dey pick up environment changes.
I still need open port 3012 for notifications?
No. Vaultwarden remove support for WebSocket traffic on port 3012 for version 1.31.0 because notifications move to the main HTTP port. WEBSOCKET_ENABLED and WEBSOCKET_PORT don dey ignored since 1.29.0. The current setting na ENABLE_WEBSOCKET, and true na the default value. Close 3012 for the firewall and remove am from your compose file. Then make sure your reverse proxy forward the Upgrade and Connection headers, because na dem real-time sync depend on now.