Is Vaultwarden Secure? A Hardening Pass
Vaultwarden encrypts every vault item in the client, so the server holds no plaintext. The real risks are your admin token and your backup file.
Is Vaultwarden secure? The short answer
Vaultwarden is secure in the one place that matters most, because every vault item is encrypted on your device before it reaches the server. The server stores blobs it cannot read. Someone who copies the whole database still needs a master password to get anything useful out of it.
That answer is doing a lot of work, and the parts that break are the parts you configure. An admin panel behind a guessable token. A container port published to the whole internet. A plaintext config.json. A backup tarball sitting in a home directory on the same box. None of those are cryptography problems. All of them are the reason self-hosted vaults get emptied.
Everything below assumes a working install. If you do not have one yet, set it up with the Vaultwarden install guide for a VPS first, then come back and work down this list in order.
What the server actually stores
Vaultwarden implements Bitwarden's data model. A vault item's name, username, password, notes and URIs are encrypted with a key derived from your master password, in the client, before any request is sent. Attachment file contents are encrypted the same way. The server receives opaque data with a UUID (universally unique identifier) attached to it.
Some things are not ciphertext, and you should know exactly which:
- Your account email address, in plaintext.
- Your KDF (key derivation function) settings and salt, because the client needs them to rebuild the key on the next login.
- A server-side hash of the master password hash the client sends, used to authenticate the login itself.
- Metadata: organisation membership, device names, last login times.
- The secret for the two-factor method that guards the Vaultwarden login. It lives in the
twofactortable unencrypted, because the server has to compute the expected code to compare against yours. This is not the same thing as a TOTP (time-based one-time password) secret you store inside a vault item, which is encrypted like any other field.
The data folder is small. On a Docker install it is whatever you mounted at /data.
sudo ls -l /vw-data/db.sqlite3 holds almost all state. attachments/ holds uploaded files, one per UUID, and it is the only important class of data that does not live in database tables. sends/ holds Send attachments and is meant to be temporary. icon_cache/ is throwaway. rsa_key.pem and its companions sign the JWTs (JSON web tokens) of logged-in users, so a copy of that private key can be used to forge a vault login session. config.json exists only once you enable the admin page, and the project is blunt about it: it holds the admin token and your SMTP credentials in plaintext.
So the practical threat model is filesystem access, not network cryptography. Read access to that one directory hands over every user's email address, their login 2FA secrets, a key that forges sessions, and an offline copy of every vault to attack at leisure. Every step below exists to keep people out of that directory.
Fix the admin token first
/admin is a full control panel: user list, invitations, deletion, every runtime setting. It is protected by one shared secret and nothing else. No username. No per-user two-factor.
Older guides tell you to generate ADMIN_TOKEN with openssl rand -base64 48. That works, and it writes the secret in plaintext into config.json and into your compose file. Vaultwarden also accepts an Argon2 PHC (password hashing competition) string, so the stored value is a 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 hashIt asks for a password twice, then prints a line beginning with $argon2id$. On a bare-metal install, run ./vaultwarden hash. If you would rather use the 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 the trap that costs people an hour. A PHC string is full of $ characters, and Docker Compose treats $ as variable interpolation. Paste it unescaped into an environment: block and the value that reaches the container is mangled, so /admin rejects a token you know is correct. Two safe forms. In docker-compose.yml, double every $:
environment:
ADMIN_TOKEN: $$argon2id$$v=19$$m=19456,t=2,p=1$$UUZxK1FZMkZoRHFQRlVrTXZvS0E3bHpNQW55c2dBN2NORzdsa0Nxd1JhND0$$cUoId+JBUsJutlG4rfDZayExfjq4TCt48aBc9qsc3UIIn an .env file, no escaping is needed, but use single quotes:
ADMIN_TOKEN='$argon2id$v=19$m=65540,t=3,p=4$MmeKRnGK5RW5mJS7h3TOL89GrpLPXJPAtTK8FTqj9HM$DqsstvoSAETl9YhnsXbf43WeaUwJC6JhViIvuPoig78'Then rate limit the panel and shorten its session:
ADMIN_RATELIMIT_SECONDS=300
ADMIN_RATELIMIT_MAX_BURST=3
ADMIN_SESSION_LIFETIME=20Three bad attempts inside five minutes and the panel stops answering that client. The admin session expires after 20 minutes of nothing.
Better than any of this: turn the page off. Most instances need it once, to configure SMTP and invite the first users, and never again. To disable it, set neither ADMIN_TOKEN nor DISABLE_ADMIN_TOKEN, remove any "admin_token" key from config.json, then recreate the container. Deleting the key from the file matters because the admin page writes settings there, and what is in config.json wins over the environment. Removing the variable alone leaves the page open.
Close registration before anyone finds the domain
SIGNUPS_ALLOWED=false
SIGNUPS_VERIFY=true
SHOW_PASSWORD_HINT=falseSIGNUPS_ALLOWED defaults to true. Leave it that way and anyone who reaches your domain gets an account, and their data sits in the same db.sqlite3 as yours. Set it to false and add people through invitations from the admin page, which needs working SMTP. INVITATIONS_ALLOWED is also true by default and lets organisation owners invite others. That is fine when you trust your users, and it should be false on a single-user instance. If only certain domains should ever register, SIGNUPS_DOMAINS_WHITELIST=example.com is narrower than open signup and much weaker than invitations.
SHOW_PASSWORD_HINT is false by default and should stay there. With it on, typing a valid email address into the login form returns that account's master password hint, which both leaks the hint and confirms the address exists.
If your instance ran with signups open for any length of time, open the admin page and read the user list before assuming you are the only account on it.
The port you did not mean to publish
The Docker image listens on port 80 inside the container. A bare-metal install defaults to ROCKET_PORT=8000. The documented run command publishes it like this:
--publish 127.0.0.1:8000:80The 127.0.0.1: prefix is the entire point. Write -p 8000:80 instead and Docker binds 0.0.0.0, and it does that by writing DNAT (destination network address translation) rules into the nat table. Those rules are evaluated before the filter chains that ufw manages, so ufw status reports the port as denied while the port cheerfully answers the internet. The full mechanism is worth reading in the guide to Docker ports bypassing ufw.
Check what is really listening:
sudo ss -tlnp | grep 8000A healthy result is a single line bound to 127.0.0.1:8000. A line bound to 0.0.0.0:8000 means the vault is exposed directly. Fix the mapping, then recreate the container, because a port binding is fixed when the container is created and docker compose restart will not change it:
docker compose up -d --force-recreateOne more port survives in old guides: 3012, the separate WebSocket port. Support for it was removed in Vaultwarden 1.31.0, because notification traffic moved onto the main HTTP port. WEBSOCKET_ENABLED and WEBSOCKET_PORT have been ignored since 1.29.0. The current switch is ENABLE_WEBSOCKET, which defaults to true. If your firewall or compose file still opens 3012, close it.
Terminate TLS at a reverse proxy, not in Rocket
Vaultwarden can serve TLS (transport layer security) itself through Rocket, its web framework, and the project tells you not to in production. Rocket's built-in TLS lacks strict SNI (server name indication) support, which is also why the hardening advice is to reach your instance by hostname and never by bare IP address. Public IP ranges are scanned constantly, and a vault that answers on an IP address is a vault that gets found.
The parts of an nginx server block that matter:
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 defaults client_max_body_size to 1 MB, so without that line an attachment upload fails with 413 Request Entity Too Large in the nginx error log while Vaultwarden logs nothing at all. The Upgrade and Connection headers carry the WebSocket handshake to /notifications/hub. Drop them and the vault still works, and changes stop appearing on your other devices until you reload the page by hand.
Caddy is shorter and obtains the certificate on its own:
vw.example.com {
reverse_proxy 127.0.0.1:8000 {
header_up X-Real-IP {remote_host}
}
}Then tell Vaultwarden about it:
DOMAIN=https://vw.example.com
IP_HEADER=X-Real-IPIP_HEADER already defaults to X-Real-IP, so the job is making sure the proxy actually sets that header. If it does not, every log line and every login rate limit sees 127.0.0.1, the proxy itself, which means one attacker's failures are counted against every user on the instance. Set DOMAIN to the real https URL too, because Vaultwarden builds invitation and password reset links from it, and WebAuthn security keys are bound to that origin.
One detail people miss: the WebSocket connection passes the session token in the query string, as /notifications/hub?access_token=[JWT]. That lands in your proxy access log in the clear. Redact the access_token parameter in the log format, or make sure those logs are not shipped anywhere you do not control.
Ban brute force at the login endpoint
Rate limits are on by default (LOGIN_RATELIMIT_SECONDS=60, LOGIN_RATELIMIT_MAX_BURST=10). They slow an attacker down. They do not stop one. fail2ban does, but Vaultwarden has to write a log file first, and it does not do that out of the box:
LOG_FILE=/data/vaultwarden.log
LOG_LEVEL=info
EXTENDED_LOGGING=trueA failed login then produces exactly one line, and this is the string your filter has to 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 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 kept the admin page, add a second jail whose failregex is ^.*Invalid admin token\. IP: <ADDR>.*$, because admin failures are logged with a different message and the login filter will never see them. Then check your work:
sudo systemctl restart fail2ban
sudo fail2ban-client status vaultwardenA working jail lists your log file under File list and reports Currently failed: 0. Type a wrong password three times from a different network and that counter rises, then the address appears under Banned IP list. If the counter never moves, the usual cause is logpath: it must be the file's path on the host, not the /data/... path inside the container. The second usual cause is a missing X-Real-IP, which makes every ban target your own proxy. The rest of the setup, including the SSH jail you should already be running, is in the fail2ban guide for Ubuntu 24.04.
The master password is still the whole system
Client-side encryption means the master password is the key. A short master password on an instance whose database an attacker has copied is protected by nothing in this post, because they attack that copy offline at whatever rate their hardware allows. No server setting reaches an attacker's own machine.
PASSWORD_ITERATIONS=600000 is the KDF iteration count handed to clients when they create a new account. Existing accounts keep the value they were created with, so raising it changes nothing for users who signed up last year. They have to change it themselves in the web vault security settings, which re-encrypts their key. Tell them, because nothing in the interface will.
Then enable two-factor authentication per account. It does not protect the ciphertext, since the vault key comes from the master password alone. It does stop a stolen password from being enough to log in and sync a copy. REQUIRE_DEVICE_EMAIL=true adds an email confirmation step the first time an account logs in from an unrecognised device.
Backups are where self-hosted vaults go wrong
A tar czf of the data folder, left in a home directory on the same VPS, undoes every step above. That archive holds db.sqlite3 with every user's ciphertext, rsa_key.pem which forges login sessions, and config.json with the admin token and SMTP password in plaintext. Read access to that one file is read access to the vault.
Two rules cover it. Get the archive off the box. Encrypt it before it leaves.
There is also a correctness problem. Copying db.sqlite3 with cp while the service is running can produce a file that is mid-write and will not open, and you will not find out until the restore. Use SQLite's own snapshot instead:
sqlite3 /vw-data/db.sqlite3 ".backup '/tmp/vw-db-backup.sqlite3'"The restore side, which is the half nobody tests, is covered in the Vaultwarden backup and restore guide.
What you give up next to hosted Bitwarden
Honest accounting. Bitwarden's hosted service is operated by people whose full-time job is operating it, with published third-party audits and someone on call at 3am. Self-hosting swaps that for your own patch cadence.
Vaultwarden ships security fixes as ordinary releases. Version 1.37.0, released 24 July 2026, is current as of August 2026, and its notes ask users to update as soon as possible. An instance you set up a year ago and forgot is running year-old code. The latest tag does not help on its own: a running container keeps the image it started with until you run docker compose pull and recreate it. Put unattended upgrades on Ubuntu in place for the host packages, and put the container update on a calendar reminder you will actually read.
The conclusion an honest reader should draw: the cryptography here is Bitwarden's design and it holds up, while the operational risk moves entirely onto you. If you patch it and you back it up somewhere else, a Vaultwarden instance on a VPS you control is a reasonable place to keep your passwords. If those two habits are not going to happen, pay for the hosted service and spend the attention elsewhere. The feature-by-feature comparison is in Vaultwarden compared with self-hosted Bitwarden.
Harden the host underneath the container
Vaultwarden is one process on a Linux box, and root on that box can read /vw-data no matter what the application is configured to do. Run the container as an unprivileged user with user: "1000:1000" in your compose file, with the data folder owned to match, and mount anything the container does not write to as read only with :ro. Then close the front door: SSH hardening on a VPS covers key-only login and disabling password authentication, which is what stops the boring attack that gets past all of the above.
FAQ
Can someone read my passwords if they steal the Vaultwarden database?
Not directly. Every vault item is encrypted in the client with a key derived from the master password, so db.sqlite3 contains ciphertext. What they get immediately is each account's email address, the KDF settings, login and device metadata, and the two-factor secrets in the twofactor table, which are stored unencrypted because the server must compute the expected code. They can also attack the vault ciphertext offline for as long as they like, which is why master password length is the number that decides the outcome.
Should I use ADMIN_TOKEN or disable the admin page completely?
Disable it if you can, since most instances need it once to configure SMTP and invite users and never again. To disable it, set neither ADMIN_TOKEN nor DISABLE_ADMIN_TOKEN, remove any "admin_token" key from config.json, then recreate the container. Removing only the environment variable is not enough, because settings written by the admin page live in config.json and take precedence. If you do keep the page, store the token as an Argon2 hash produced by vaultwarden hash rather than a plaintext random string, and set ADMIN_RATELIMIT_MAX_BURST=3.
My ADMIN_TOKEN is correct but /admin rejects it. What is wrong?
Almost always $ interpolation. An Argon2 PHC string contains several $ characters, and Docker Compose expands them as variables inside a docker-compose.yml environment: block, so the container receives a mangled value while your file looks right. Double every $ to $$ in the compose file, or move the value into an .env file wrapped in single quotes, where no escaping is needed. Recreate the container afterwards, since environment changes are not picked up by a restart.
Do I still need to open port 3012 for notifications?
No. Support for WebSocket traffic on port 3012 was removed in Vaultwarden 1.31.0 because notifications moved onto the main HTTP port, and WEBSOCKET_ENABLED and WEBSOCKET_PORT have been ignored since 1.29.0. The current setting is ENABLE_WEBSOCKET, which is true by default. Close 3012 in the firewall and delete it from your compose file, then make sure your reverse proxy forwards the Upgrade and Connection headers, because that is what real-time sync actually depends on now.