Vaultwarden 安全吗?自托管密码库加固指南
Vaultwarden 采用客户端加密,服务器仅存储不可读数据。本文深入分析安全风险,重点解决管理令牌泄露、明文配置文件及备份文件安全等关键漏洞,助您构建高安全性密码库。
Vaultwarden 安全吗?简短回答
Vaultwarden 在最关键的环节是安全的,因为每个密码库项目都会先在您的设备上加密,然后才发送到服务器。服务器存储的是无法读取的加密数据块。即使有人复制了整个数据库,仍然需要主密码才能获取其中的有效内容。
但这个回答包含了很多前提,而最容易出问题的部分正是您配置的内容。例如,管理面板使用了容易猜到的令牌;容器端口发布到了整个互联网;存在明文 config.json;备份 tar 包存放在同一台服务器的主目录中。这些都不是密码学问题,却都是自托管密码库被清空的原因。
下面的内容都假设您已经完成安装并且服务正常运行。如果还没有安装,请先按照 VPS 上的 Vaultwarden 安装指南 完成设置,然后按顺序检查下面的列表。
服务器实际存储的内容
Vaultwarden 实现了 Bitwarden 的数据模型。Vault 项目的名称、用户名、密码、备注和 URI 会在客户端中使用主密码派生的密钥加密,然后才发送请求。附件文件内容也使用相同方式加密。服务器收到的是附带 UUID(通用唯一标识符)的不可读数据。
有些内容不是密文,您应准确了解具体包括哪些:
- 您的账户电子邮件地址,以明文形式存储。
- 您的 KDF(密钥派生函数)设置和盐值,因为客户端下次登录时需要使用它们重新生成密钥。
- 客户端发送的主密码哈希的服务器端哈希,用于对登录本身进行身份验证。
- 元数据:组织成员关系、设备名称、上次登录时间。
- 用于保护 Vaultwarden 登录的双因素身份验证方法的密钥。该密钥未加密地存储在
twofactor表中,因为服务器必须计算预期代码并与您提交的代码比较。这不同于您存储在 Vault 项目内部的 TOTP(基于时间的一次性密码)密钥;后者与其他字段一样经过加密。
数据目录很小。在 Docker 安装中,它就是您挂载到 /data 的目录。
sudo ls -l /vw-data/db.sqlite3 保存几乎所有状态。attachments/ 保存上传的文件,每个 UUID 对应一个文件;这是唯一一类不存储在数据库表中的重要数据。sends/ 保存 Send 附件,设计用途是临时存储。icon_cache/ 可随时删除。rsa_key.pem 及其配套文件用于签署已登录用户的 JWT(JSON Web Token);因此,私钥副本可用于伪造 Vault 登录会话。只有启用管理页面后才会生成 config.json,项目对此说明得很直接:其中以明文保存管理员令牌和 SMTP 凭据。
因此,实际的威胁模型是文件系统访问,而不是网络加密。只要能够读取该目录,就能获取所有用户的电子邮件地址、登录双因素身份验证密钥、用于伪造会话的密钥,以及每个 Vault 的离线副本,并可以从容发起攻击。下面的每一步都是为了阻止他人访问该目录。
先修复管理令牌
/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接下来是一个容易浪费 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=205 分钟内连续失败 3 次后,控制面板将停止响应来自该客户端的请求。登录后 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,应保持此设置。启用该选项且未配置 SMTP 时,在登录表单中输入有效的电子邮件地址会返回该账户的主密码提示。这既会泄露密码提示,也会确认该地址已存在。
如果您的实例曾在一段时间内开放注册,请打开管理页面并查看用户列表,不要直接假定其中只有您的账户。
您并不打算发布的端口
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 端口。WEBSOCKET_ENABLED 和 WEBSOCKET_PORT 自 1.29.0 起已弃用,并且会被忽略。当前使用的开关是 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,因此需要确保代理实际设置了该请求头。Vaultwarden 还只接受来自 IP_HEADER_TRUSTED_PROXIES 所列地址的该请求头。IP_HEADER_TRUSTED_PROXIES 的默认值为 local,即所有非公网地址,包括同一主机上的代理或同一容器网络中的代理。如果代理从公网地址连接,则必须将该地址添加到其中。如果请求头缺失,或来自不受信任的地址,那么每条日志记录和每个登录速率限制都会使用代理自身的地址。这意味着一个攻击者的失败尝试会计入该实例上的所有用户。如果使用 IP_HEADER=X-Forwarded-For,1.37.4 会采用不是受信任代理的最右侧地址。因此,应列出代理链中的每个代理,包括 nginx 前面的 CDN。还应将 DOMAIN 设置为实际的 https URL,因为 Vaultwarden 会据此生成邀请链接和密码重置链接,WebAuthn 安全密钥也会绑定到该来源。
还有一个容易忽略的细节:WebSocket 连接会将会话令牌放在查询字符串中,即 /notifications/hub?access_token=[JWT]。该令牌会以明文形式出现在代理访问日志中。应在日志格式中对 access_token 参数进行脱敏,或确保这些日志不会发送到任何不受您控制的位置。
禁止登录端点遭受暴力破解
默认已启用速率限制(LOGIN_RATELIMIT_SECONDS=60、LOGIN_RATELIMIT_MAX_BURST=10)。它只能降低攻击者的尝试速度,不能阻止攻击。fail2ban 可以阻止攻击,但需要在可读取的位置获取 Vaultwarden 的登录失败记录。在 Docker 中,最简单的方法是使用日志文件,而 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如果 Vaultwarden 作为 systemd 服务运行,而不是在容器中运行,可以跳过 LOG_FILE:该项目的 fail2ban wiki 使用 backend = systemd 读取 journal,并在 Vaultwarden 单元上配置 journalmatch,以替代 logpath。
如果保留了管理页面,请添加第二个 jail,并将其 failregex 设置为 ^.*Invalid admin token\. IP: <ADDR>.*$,因为管理页面的失败登录使用不同的消息记录,登录过滤器无法匹配这些消息。然后检查配置:
sudo systemctl restart fail2ban
sudo fail2ban-client status vaultwarden正常工作的 jail 会在 File list 下列出日志文件,并报告 Currently failed: 0。从其他网络连续输入错误密码三次,该计数器应增加,随后该地址会出现在 Banned IP list 下。如果计数器始终不变,通常是 logpath 配置错误:这里必须填写主机上的文件路径,而不是容器内的 /data/... 路径。另一个常见原因是缺少或不受信任的 X-Real-IP,这会导致每次封禁的目标都是您自己的代理。其余配置(包括您应该已经运行的 SSH jail)请参阅Ubuntu 24.04 的 fail2ban 指南。
主密码仍然决定整个系统的安全性
客户端加密意味着主密码就是密钥。如果攻击者已经复制了实例的数据库,那么本文中的任何设置都无法保护短主密码,因为攻击者可以在离线环境中按照其硬件允许的速度破解该副本。服务器上的设置无法影响攻击者自己的设备。
PASSWORD_ITERATIONS=600000 容易被误解。它设置的是服务器端对客户端发送的登录哈希执行哈希运算时的迭代次数。项目的配置模板说明,修改后的值会在现有用户下次登录时应用。它不会影响客户端 KDF,而客户端 KDF 才决定离线破解保管库的成本。该设置属于每个账户,可在 Web 保管库的 Settings、Security、Keys 中修改;修改后会重新加密密钥。Bitwarden 文档说明,自 2026.2.1 起,其客户端会在用户使用主密码登录或解锁时,自动将低于 600,000 次迭代的 PBKDF2 设置提高到该值。截至 2026 年 10 月 10 日,尚未确认 Vaultwarden 服务器支持此行为,而且该行为不会修改 Argon2id 设置。因此,数年前注册的用户可能仍在使用较低的旧 KDF 设置。请检查并告知这些用户,因为服务器无法代替他们完成此操作。
然后为每个账户启用双因素身份验证。它无法保护密文,因为保管库密钥仅来自主密码。它可以防止攻击者仅凭窃取的密码登录并同步副本。REQUIRE_DEVICE_EMAIL=true 会强制要求发送新设备登录邮件:如果 Vaultwarden 无法发送该邮件,登录就会失败,因此来自未识别设备的登录不会在没有通知的情况下发生。该功能需要可用的 SMTP,否则所有新设备登录都会被拒绝。
备份是自托管保险库最容易出问题的环节
将数据目录 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 将安全修复作为常规版本发布,而且发布频率较高。截至 2026 年 10 月 10 日,根据项目的发布源检查,当前版本仍为 1.37.4,于 2026 年 10 月 5 日发布。其发布说明涵盖 7 个安全公告,最严重的问题评级为 High,并强烈建议尽快更新。这是自 2026 年 7 月 24 日发布 1.37.0 后的第 4 个版本:1.37.1 于 7 月 29 日发布,1.37.2 于 8 月 22 日发布(2026.8.0 及更高版本的客户端必须使用该版本),1.37.3 于 9 月 13 日发布。1.37.3 会在凭据或 2FA 发生变化时撤销 2FA 记住令牌,并限制 prelogin 和 auth 请求端点的请求速率。如果你运行的组织中包含并不完全信任的管理员,1.37.4 的发布说明还建议在更新后轮换每个组织的 API key。1.37.4 还会改变从 X-Forwarded-For 读取地址的方式,上面的反向代理部分对此有说明。一年前部署、之后遗忘的实例,运行的仍是一年前的代码。仅使用 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 标头,因为实时同步现在实际依赖这些标头。