Vaultwarden安全吗?自托管加固检查清单
Vaultwarden 客户端加密保险库条目,服务器不保存明文。本文逐项检查管理令牌、开放端口、明文配置和备份文件这四类实际风险。
Vaultwarden 是否安全?简短回答
Vaultwarden 在最关键的环节是安全的,因为每个保险库条目都会先在您的设备上加密,然后才发送到服务器。服务器存储的是自身无法读取的数据块。即使有人复制了整个数据库,也仍然需要主密码才能从中获取有用信息。
这个回答涉及很多配置细节,而真正容易出问题的正是这些配置。使用容易猜到的令牌保护管理面板。将容器端口发布到整个互联网。明文 config.json。将备份 tarball 放在同一台服务器的主目录中。这些都不是密码学问题,但都是自托管保险库被清空的原因。
以下内容假定您已经完成安装并确认服务正常运行。如果还没有安装,请先按照 VPS 上的 Vaultwarden 安装指南完成设置,然后按顺序检查下面的列表。
服务器实际存储的内容
Vaultwarden 使用 Bitwarden 的数据模型。保险库项目的名称、用户名、密码、备注和 URI 会在客户端中使用由主密码派生的密钥加密,然后才发送请求。附件文件内容也以相同方式加密。服务器收到的是附带 UUID(通用唯一标识符)的不可读数据。
有些内容不是密文,您应准确了解具体包括哪些:
- 您的账户电子邮件地址,以明文存储。
- 您的 KDF(密钥派生函数)设置和盐值,因为客户端下次登录时需要使用它们重新生成密钥。
- 客户端发送的主密码哈希的服务器端哈希,用于验证登录本身。
- 元数据:组织成员关系、设备名称、上次登录时间。
- 用于保护 Vaultwarden 登录的双因素验证方法密钥。该密钥未加密,存储在
twofactor表中,因为服务器必须计算预期验证码并与您提供的验证码进行比较。这与您存储在保险库项目内部的 TOTP(基于时间的一次性密码)密钥不同,后者会像其他字段一样加密。
数据目录很小。在 Docker 安装中,它就是您挂载到 /data 的目录。
sudo ls -l /vw-data/db.sqlite3 保存几乎所有状态。attachments/ 保存上传的文件,每个 UUID 对应一个文件;这是唯一重要且不存储在数据库表中的数据类别。sends/ 保存 Send 附件,设计用途是临时存储。icon_cache/ 可随时删除。rsa_key.pem 及其配套文件用于签署已登录用户的 JWT(JSON Web Token)。因此,私钥副本可用于伪造保险库登录会话。只有启用管理页面后才会生成 config.json,项目文档对此说明得很明确:其中以明文保存管理令牌和 SMTP 凭据。
因此,实际威胁模型是文件系统访问,而不是网络加密。只要能够读取这个目录,攻击者就能获得所有用户的电子邮件地址、登录双因素验证密钥、用于伪造会话的密钥,以及每个保险库的离线副本,并可随意进行离线攻击。下面的每一步都是为了阻止他人访问该目录。
先修复管理令牌
/admin是一个完整的控制面板:用户列表、邀请、删除和所有运行时设置。它只由一个共享密钥保护,没有其他防护措施。没有用户名,也没有按用户配置的双因素认证。
旧版指南会告诉您使用 openssl rand -base64 48 生成 ADMIN_TOKEN。这种方法可用,但会将密钥以明文写入 config.json 和 compose 文件。Vaultwarden 也接受 Argon2 PHC(密码哈希竞赛)字符串,因此存储的值可以改为哈希。请对正在运行的容器生成一个:
docker exec -it vaultwarden /vaultwarden hash也可以完全不接触正在运行的容器:
docker run --rm -it vaultwarden/server /vaultwarden hash该命令会要求您输入两次密码,然后输出一行以 $argon2id$ 开头的内容。在裸机安装中,请运行 ./vaultwarden hash。如果您更希望直接使用 argon2 CLI,该项目给出了符合 OWASP 最低要求的参数:
echo -n 'MySecretPassword' | argon2 "$(openssl rand -base64 32)" -e -id -k 19456 -t 2 -p 1现在介绍一个容易让人浪费一小时的陷阱。PHC 字符串包含大量 $ 字符,而 Docker Compose 会将 $ 视为变量插值。将其未经转义地粘贴到 environment: 块中后,传入容器的值会被破坏,因此 /admin 会拒绝一个您确认正确的令牌。有两种安全写法。在 docker-compose.yml 中,将每个 $ 加倍:
environment:
ADMIN_TOKEN: $$argon2id$$v=19$$m=19456,t=2,p=1$$UUZxK1FZMkZoRHFQRlVrTXZvS0E3bHpNQW55c2dBN2NORzdsa0Nxd1JhND0$$cUoId+JBUsJutlG4rfDZayExfjq4TCt48aBc9qsc3UI在 .env 文件中不需要转义,但请使用单引号:
ADMIN_TOKEN='$argon2id$v=19$m=65540,t=3,p=4$MmeKRnGK5RW5mJS7h3TOL89GrpLPXJPAtTK8FTqj9HM$DqsstvoSAETl9YhnsXbf43WeaUwJC6JhViIvuPoig78'然后限制面板请求速率,并缩短其会话时长:
ADMIN_RATELIMIT_SECONDS=300
ADMIN_RATELIMIT_MAX_BURST=3
ADMIN_SESSION_LIFETIME=20在五分钟内连续三次尝试失败后,面板会停止响应此客户端。管理会话在 20 分钟无活动后过期。
更好的做法是直接关闭该页面。大多数实例只需使用一次该页面来配置 SMTP 和邀请第一批用户,之后就不再需要。要禁用它,请不要设置 ADMIN_TOKEN 或 DISABLE_ADMIN_TOKEN,从 config.json 中删除任何 "admin_token" 键,然后重新创建容器。删除文件中的键很重要,因为管理页面会将设置写入该文件,而 config.json 中的内容优先于环境变量。仅删除环境变量并不会关闭该页面。
关闭注册,避免他人抢先注册您的域名
SIGNUPS_ALLOWED=false
SIGNUPS_VERIFY=true
SHOW_PASSWORD_HINT=falseSIGNUPS_ALLOWED默认为 true。保持此设置不变时,任何访问您域名的人都可以创建账户,其数据也会与您的数据存放在同一个 db.sqlite3 中。将其设置为 false,然后通过管理页面发送邀请来添加用户;此功能需要可用的 SMTP。INVITATIONS_ALLOWED 默认也为 true,允许组织所有者邀请其他人。只有在您信任所有用户时,这样设置才合适;对于单用户实例,应将其设置为 false。如果只允许特定域名注册,SIGNUPS_DOMAINS_WHITELIST=example.com 比开放注册范围更小,但安全性仍远低于邀请制。
SHOW_PASSWORD_HINT 默认设置为 false,应保持不变。启用此设置后,在登录表单中输入有效的电子邮件地址,会返回该账户的主密码提示。这不仅会泄露密码提示,还会确认该地址已注册。
如果您的实例曾在一段时间内开放注册,请先打开管理页面查看用户列表,不要直接假设实例中只有您的账户。
未打算公开的端口
Docker 镜像在容器内监听端口 80。裸机安装默认使用 ROCKET_PORT=8000。文档中的运行命令会这样发布该端口:
--publish 127.0.0.1:8000:80127.0.0.1: 前缀才是关键。改为 -p 8000:80 后,Docker 会绑定 0.0.0.0,并将 DNAT(目标网络地址转换)规则写入 nat 表。这些规则会在 ufw 管理的 filter 链之前评估,因此 ufw status 会报告该端口被拒绝,但该端口仍会直接响应互联网请求。完整机制请参阅Docker 端口绕过 ufw 的指南。
检查实际监听情况:
sudo ss -tlnp | grep 8000正常结果应只有一行绑定到 127.0.0.1:8000。如果某行绑定到 0.0.0.0:8000,说明 vault 已直接暴露。修正端口映射,然后重新创建容器,因为端口绑定在创建容器时就已确定,docker compose restart 不会修改它:
docker compose up -d --force-recreate旧指南中还会出现另一个端口:3012,即独立的 WebSocket 端口。Vaultwarden 1.31.0 移除了该端口的支持,因为通知流量已转移到主 HTTP 端口。自 1.29.0 起,WEBSOCKET_ENABLED 和 WEBSOCKET_PORT 已被忽略。当前开关是 ENABLE_WEBSOCKET,默认值为 true。如果防火墙或 compose 文件仍开放 3012,请关闭该端口。
在反向代理处终止 TLS,而不是在 Rocket 中终止
Vaultwarden 可以通过其 Web 框架 Rocket 自行提供 TLS(传输层安全),但项目明确建议不要在生产环境中这样做。Rocket 内置的 TLS 不支持严格的 SNI(服务器名称指示),这也是加固建议要求您通过主机名访问实例、绝不直接使用裸 IP 地址的原因。公网 IP 地址段会持续受到扫描,能够通过 IP 地址响应的 Vault 就会被发现。
nginx server block 中需要关注的部分:
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 默认将 client_max_body_size 设置为 1 MB,因此如果没有这一行,附件上传会失败;nginx 错误日志中会出现 413 Request Entity Too Large,而 Vaultwarden 完全不会记录任何内容。Upgrade 和 Connection 请求头会将 WebSocket 握手传递给 /notifications/hub。如果省略它们,Vault 仍然可以正常工作,但更改将不再出现在您的其他设备上,除非您手动重新加载页面。
Caddy 的配置更短,并且会自行获取证书:
vw.example.com {
reverse_proxy 127.0.0.1:8000 {
header_up X-Real-IP {remote_host}
}
}然后告知 Vaultwarden 相关信息:
DOMAIN=https://vw.example.com
IP_HEADER=X-Real-IPIP_HEADER 默认已经设置为 X-Real-IP,因此需要确保代理确实设置了该请求头。如果没有设置,每条日志记录和每个登录速率限制都会将 127.0.0.1(即代理本身)视为客户端。这意味着一个攻击者的失败尝试会计入该实例中的所有用户。还应将 DOMAIN 设置为实际的 https URL,因为 Vaultwarden 会据此生成邀请链接和密码重置链接,WebAuthn 安全密钥也会绑定到该来源。
还有一个容易忽略的细节:WebSocket 连接会在查询字符串中以 /notifications/hub?access_token=[JWT] 的形式传递会话令牌。该令牌会以明文形式写入代理访问日志。应在日志格式中对 access_token 参数进行脱敏,或确保这些日志不会发送到您无法控制的任何位置。
禁止登录端点遭受暴力破解
速率限制默认已启用(LOGIN_RATELIMIT_SECONDS=60、LOGIN_RATELIMIT_MAX_BURST=10)。它可以减慢攻击者的尝试速度,但无法阻止攻击者。fail2ban 可以阻止攻击,但 Vaultwarden 必须先写入日志文件,而它默认不会这样做:
LOG_FILE=/data/vaultwarden.log
LOG_LEVEL=info
EXTENDED_LOGGING=true登录失败后会准确生成一行日志,这就是过滤器必须匹配的字符串:
[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.将过滤器写入 /etc/fail2ban/filter.d/vaultwarden.local:
[INCLUDES]
before = common.conf
[Definition]
failregex = ^.*?Username or password is incorrect\. Try again\. IP: <ADDR>\. Username:.*$
ignoreregex =将 jail 写入 /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 = 14400如果保留了管理页面,请添加第二个 jail,并将其 failregex 设置为 ^.*Invalid admin token\. IP: <ADDR>.*$,因为管理操作失败时会记录不同的消息,登录过滤器永远无法匹配这些消息。然后检查配置:
sudo systemctl restart fail2ban
sudo fail2ban-client status vaultwarden正常工作的 jail 会在 File list 下列出日志文件,并报告 Currently failed: 0。从其他网络连续输入3次错误密码,该计数器应增加,随后该地址会出现在 Banned IP list 下。如果计数器始终不变,通常是因为 logpath 配置错误:这里必须填写主机上的文件路径,而不是容器内的 /data/... 路径。另一个常见原因是缺少 X-Real-IP,这会使所有封禁目标都变成您自己的代理。其余配置(包括您应该已经运行的 SSH jail)请参阅Ubuntu 24.04 的 fail2ban 指南。
主密码仍然决定整个系统的安全性
客户端加密意味着主密码就是密钥。如果攻击者已经复制了实例的数据库,那么本节中的任何设置都无法保护短主密码,因为攻击者可以在离线环境中以其硬件允许的速度破解该副本。服务器上的设置无法限制攻击者自己机器上的攻击速度。
PASSWORD_ITERATIONS=600000 是用户创建新账户时下发给客户端的 KDF 迭代次数。现有账户会继续使用创建账户时的值,因此提高该值不会影响去年注册的用户。他们必须在 Web vault 的安全设置中自行修改该值,这会重新加密其密钥。请明确告知用户这一点,因为界面不会提示他们。
然后为每个账户启用双因素身份验证。它不会保护密文,因为 vault 密钥仅来自主密码。它可以防止攻击者仅凭窃取的密码登录并同步副本。REQUIRE_DEVICE_EMAIL=true 会在账户首次从未识别的设备登录时增加电子邮件确认步骤。
自托管保险库最容易在备份环节出问题
将数据目录压缩为 tar czf,并把它留在同一台 VPS 的主目录中,会使前面的所有步骤失去意义。该归档文件包含 db.sqlite3,其中存储了每个用户的密文;包含 rsa_key.pem,可用于伪造登录会话;还包含 config.json,其中以明文存储管理员令牌和 SMTP 密码。对这一个文件具有读取权限,就等于可以读取整个保险库。
两条规则足以覆盖这一问题。将归档文件移出此服务器。在归档文件离开服务器前对其加密。
此外,还存在数据一致性问题。服务运行期间使用 cp 复制 db.sqlite3,可能会得到一个正在写入中的文件。该文件将无法打开,而你要到恢复时才会发现问题。请改用 SQLite 自带的快照:
sqlite3 /vw-data/db.sqlite3 ".backup '/tmp/vw-db-backup.sqlite3'"恢复端是很少有人测试的部分,相关操作请参阅Vaultwarden 备份与恢复指南。
相较于托管版 Bitwarden,您需要放弃什么
如实评估。Bitwarden 托管服务由全职负责运营的人员维护,并提供公开的第三方审计报告,还有人员在凌晨 3 点值班。自行托管意味着您需要自行安排补丁更新节奏。
Vaultwarden 通过常规版本发布安全修复。版本 1.37.0 于 24 July 2026 发布,截至 August 2026 仍为当前版本,其发布说明要求用户尽快更新。一年前部署、之后被遗忘的实例,运行的仍是一年前的代码。仅有 latest 标签并不能解决问题:正在运行的容器会继续使用其启动时所用的镜像,直到您运行 docker compose pull 并重新创建容器。请为主机软件包配置 Ubuntu 无人值守升级,并在日历中设置一个您确实会查看的容器更新提醒。
诚实的读者应得出这样的结论:这里的加密设计采用 Bitwarden 的方案,安全性经得起检验,但运维风险完全转移到了您身上。如果您会及时打补丁,并将备份保存到其他位置,那么在您控制的 VPS 上运行 Vaultwarden,是保存密码的合理方式。如果您无法坚持这两项习惯,请购买托管服务,把精力用于其他事项。功能逐项对比请参阅 Vaultwarden 与自托管 Bitwarden 的对比。
容器底层主机加固
Vaultwarden 只是 Linux 主机上的一个进程。无论应用如何配置,主机上的 root 都可以读取 /vw-data。在 compose 文件中使用 user: "1000:1000" 让容器以非特权用户运行,并将数据目录的所有者设置为匹配的用户。对于容器不需要写入的内容,使用 :ro 以只读方式挂载。然后关闭入口:VPS 上的 SSH 加固介绍了仅使用密钥登录和禁用密码身份验证的方法,可以阻止绕过上述所有防护的常见攻击。
FAQ
如果 Vaultwarden 数据库被盗,他人能读取我的密码吗?
不能直接读取。每个密码库项目都会在客户端使用由主密码派生的密钥进行加密,因此 db.sqlite3 中保存的是密文。他们立即获得的信息包括每个账户的电子邮件地址、KDF 设置、登录和设备元数据,以及 twofactor 表中的双因素认证密钥。这些密钥未加密存储,因为服务器必须计算预期验证码。他们还可以在离线状态下无限期尝试破解密码库密文。因此,最终结果取决于主密码的长度。
我应该使用 ADMIN_TOKEN,还是完全禁用管理页面?
如果可以,建议禁用管理页面。大多数实例只需使用它配置一次 SMTP 和邀请用户,之后通常不再需要。要禁用它,请不要设置 ADMIN_TOKEN 或 DISABLE_ADMIN_TOKEN,从 config.json 中删除所有 "admin_token" 键,然后重新创建容器。仅删除环境变量并不足够,因为管理页面写入的设置保存在 config.json 中,并且优先级更高。如果保留管理页面,请使用 vaultwarden hash 生成 Argon2 哈希,并将其作为令牌保存,而不是使用明文随机字符串,然后设置 ADMIN_RATELIMIT_MAX_BURST=3。
我的 ADMIN_TOKEN 正确,但 /admin 拒绝了它。哪里出错了?
几乎总是 $ 插值导致的问题。Argon2 PHC 字符串包含多个 $ 字符,而 Docker Compose 会在 docker-compose.yml environment: 块中将它们展开为变量。因此,容器接收到的是被破坏的值,尽管文件内容看起来正确。在 compose 文件中将每个 $ 加倍为 $$,或者将该值移入使用单引号包裹的 .env 文件中,这样就无需转义。之后重新创建容器,因为重启不会重新读取环境变量的变化。
通知仍然需要开放 3012 端口吗?
不需要。Vaultwarden 1.31.0 移除了 3012 端口上的 WebSocket 流量支持,因为通知已迁移到主 HTTP 端口;WEBSOCKET_ENABLED 和 WEBSOCKET_PORT 自 1.29.0 起也已被忽略。当前设置为 ENABLE_WEBSOCKET,默认值为 true。请在防火墙中关闭 3012,并从 compose 文件中删除该端口映射,然后确认反向代理转发 Upgrade 和 Connection 请求头,因为实时同步现在实际依赖这些请求头。