SSD Nodes Learn 🎉 VPS từ $4.99/tháng
Hướng dẫn Matt ConnorBởi Matt Connor

Vaultwarden có an toàn không? Cách hardening đúng

Vaultwarden mã hóa mọi mục trong client, nên server không giữ plaintext. Kiểm tra admin token, cổng public và file backup, vì đó mới là rủi ro chính.

Vaultwarden có an toàn không? Câu trả lời ngắn gọn

Vaultwarden an toàn ở nơi quan trọng nhất: mọi mục trong vault đều được mã hóa trên thiết bị của bạn trước khi được gửi đến server. Server chỉ lưu các blob mà nó không thể đọc. Ngay cả khi ai đó sao chép toàn bộ database, họ vẫn cần master password để lấy được dữ liệu hữu ích.

Câu trả lời này phụ thuộc nhiều vào cách bạn cấu hình. Admin panel nằm sau một token dễ đoán. Một cổng của container được publish ra toàn bộ Internet. Một config.json dạng plaintext. Một file tar backup nằm trong thư mục home trên chính máy đó. Không vấn đề nào trong số này liên quan đến cryptography. Nhưng tất cả đều có thể khiến vault self-hosted bị rút sạch dữ liệu.

Phần dưới đây giả định rằng bạn đã có một cài đặt đang hoạt động. Nếu chưa có, trước tiên hãy thiết lập bằng hướng dẫn cài đặt Vaultwarden trên VPS, sau đó quay lại và làm lần lượt từng mục trong danh sách này.

Máy chủ thực sự lưu gì

Vaultwarden triển khai mô hình dữ liệu của Bitwarden. Tên, username, password, ghi chú và URI của một mục trong vault được mã hóa bằng key dẫn xuất từ master password, ngay trong client, trước khi client gửi bất kỳ request nào. Nội dung file đính kèm cũng được mã hóa theo cách tương tự. Máy chủ nhận dữ liệu không thể đọc được, kèm theo một UUID (universally unique identifier).

Một số dữ liệu không được mã hóa, và bạn cần biết chính xác đó là những gì:

  • Địa chỉ email tài khoản của bạn, ở dạng plaintext.
  • Thiết lập KDF (key derivation function) và salt, vì client cần chúng để tạo lại key trong lần đăng nhập tiếp theo.
  • Hash phía máy chủ của password hash do client gửi, dùng để xác thực chính lần đăng nhập đó.
  • Metadata: thông tin thành viên tổ chức, tên thiết bị, thời điểm đăng nhập gần nhất.
  • Secret của phương thức two-factor bảo vệ lần đăng nhập vào Vaultwarden. Secret này nằm trong bảng twofactor ở dạng không mã hóa, vì máy chủ phải tính code dự kiến để so sánh với code của bạn. Đây không phải là secret TOTP (time-based one-time password) mà bạn lưu bên trong một mục vault. Secret đó được mã hóa như mọi trường dữ liệu khác.

Thư mục dữ liệu có kích thước nhỏ. Với bản cài Docker, đó là thư mục bạn mount vào /data.

sudo ls -l /vw-data/

db.sqlite3 chứa gần như toàn bộ state. attachments/ chứa các file đã upload, mỗi file tương ứng với một UUID. Đây là loại dữ liệu quan trọng duy nhất không nằm trong các bảng database. sends/ chứa các file đính kèm của Send và được thiết kế để lưu tạm thời. icon_cache/ là dữ liệu có thể xóa bỏ. rsa_key.pem và các file liên quan dùng để ký JWT (JSON web token) của người dùng đã đăng nhập. Vì vậy, bản sao của private key đó có thể được dùng để giả mạo session đăng nhập vào vault. config.json chỉ xuất hiện sau khi bạn bật trang admin. Project ghi rõ điều này: thư mục này chứa admin token và thông tin xác thực SMTP của bạn ở dạng plaintext.

Vì vậy, threat model thực tế là quyền truy cập filesystem, không phải cryptography trên network. Quyền đọc thư mục đó cho phép lấy toàn bộ địa chỉ email của người dùng, secret 2FA đăng nhập của họ, key dùng để giả mạo session và một bản sao offline của mọi vault để tấn công tùy ý. Mọi bước bên dưới đều nhằm ngăn người khác truy cập thư mục đó.

Sửa admin token trước

/admin là một control panel có toàn quyền: danh sách người dùng, lời mời, xóa người dùng và mọi runtime setting. Nó chỉ được bảo vệ bằng một shared secret duy nhất. Không có username. Không có two-factor riêng cho từng người dùng.

Các hướng dẫn cũ yêu cầu bạn tạo ADMIN_TOKEN bằng openssl rand -base64 48. Cách này hoạt động, nhưng ghi secret dưới dạng plaintext vào config.json và file compose của bạn. Vaultwarden cũng chấp nhận chuỗi Argon2 PHC (password hashing competition), vì vậy giá trị được lưu là một hash. Hãy tạo hash bằng container đang chạy:

docker exec -it vaultwarden /vaultwarden hash

Hoặc không cần tác động đến container đang chạy:

docker run --rm -it vaultwarden/server /vaultwarden hash

Lệnh này yêu cầu nhập password hai lần, sau đó in ra một dòng bắt đầu bằng $argon2id$. Với bản cài đặt bare-metal, chạy ./vaultwarden hash. Nếu muốn dùng trực tiếp CLI argon2, project công bố các tham số tối thiểu theo OWASP:

echo -n 'MySecretPassword' | argon2 "$(openssl rand -base64 32)" -e -id -k 19456 -t 2 -p 1

Đây là lỗi dễ khiến bạn mất cả giờ. Chuỗi PHC chứa rất nhiều ký tự $, còn Docker Compose xử lý $ như phép nội suy biến. Nếu dán chuỗi đó chưa escape vào block environment:, giá trị đến container sẽ bị biến đổi, nên /admin từ chối một token mà bạn biết là đúng. Có 2 cách an toàn. Trong docker-compose.yml, nhân đôi mọi $:

environment:
  ADMIN_TOKEN: $$argon2id$$v=19$$m=19456,t=2,p=1$$UUZxK1FZMkZoRHFQRlVrTXZvS0E3bHpNQW55c2dBN2NORzdsa0Nxd1JhND0$$cUoId+JBUsJutlG4rfDZayExfjq4TCt48aBc9qsc3UI

Trong file .env, không cần escape, nhưng phải dùng dấu nháy đơn:

ADMIN_TOKEN='$argon2id$v=19$m=65540,t=3,p=4$MmeKRnGK5RW5mJS7h3TOL89GrpLPXJPAtTK8FTqj9HM$DqsstvoSAETl9YhnsXbf43WeaUwJC6JhViIvuPoig78'

Sau đó giới hạn rate của panel và rút ngắn session:

ADMIN_RATELIMIT_SECONDS=300
ADMIN_RATELIMIT_MAX_BURST=3
ADMIN_SESSION_LIFETIME=20

Nếu có 3 lần thử sai trong vòng 5 phút, panel sẽ ngừng phản hồi với client đó. Admin session hết hạn sau 20 phút không hoạt động.

Cách tốt hơn tất cả các cách trên: tắt trang này. Hầu hết instance chỉ cần trang này một lần để cấu hình SMTP và mời những người dùng đầu tiên, sau đó không cần nữa. Để tắt, không đặt ADMIN_TOKEN hoặc DISABLE_ADMIN_TOKEN, xóa mọi key "admin_token" khỏi config.json, rồi tạo lại container. Việc xóa key khỏi file rất quan trọng vì trang admin ghi setting vào đó, và giá trị trong config.json sẽ được ưu tiên hơn biến môi trường. Chỉ xóa biến thôi thì trang vẫn mở.

Đóng đăng ký trước khi người khác phát hiện domain

SIGNUPS_ALLOWED=false
SIGNUPS_VERIFY=true
SHOW_PASSWORD_HINT=false

SIGNUPS_ALLOWED mặc định là true. Giữ nguyên như vậy nghĩa là bất kỳ ai truy cập được domain đều có thể tạo account, và dữ liệu của họ nằm trong cùng db.sqlite3 với dữ liệu của bạn. Đặt thành false rồi thêm người dùng qua lời mời từ trang quản trị; cách này cần SMTP hoạt động. INVITATIONS_ALLOWED cũng mặc định là true và cho phép owner của tổ chức mời người khác. Điều này phù hợp khi bạn tin tưởng người dùng, và nên được đặt thành false trên instance chỉ dành cho một người dùng. Nếu chỉ một số domain nhất định được phép đăng ký, SIGNUPS_DOMAINS_WHITELIST=example.com hẹp hơn đăng ký mở nhưng kém an toàn hơn nhiều so với hình thức mời.

SHOW_PASSWORD_HINT mặc định là false và nên giữ nguyên như vậy. Khi bật tùy chọn này, nhập một địa chỉ email hợp lệ vào form đăng nhập sẽ trả về gợi ý master password của account đó. Điều này vừa làm lộ gợi ý, vừa xác nhận địa chỉ email tồn tại.

Nếu instance của bạn từng cho phép đăng ký mở trong bất kỳ khoảng thời gian nào, hãy mở trang quản trị và xem danh sách người dùng trước khi cho rằng instance chỉ có account của bạn.

Cổng bạn không định public

Docker image lắng nghe trên port 80 bên trong container. Bản cài bare-metal mặc định dùng ROCKET_PORT=8000. Lệnh run được tài liệu hướng dẫn publish như sau:

--publish 127.0.0.1:8000:80

Tiền tố 127.0.0.1: mới là phần quan trọng. Nếu viết -p 8000:80 thay vào đó, Docker sẽ bind 0.0.0.0 và tạo các rule DNAT (destination network address translation) trong bảng nat. Các rule này được đánh giá trước các chain filter do ufw quản lý. Vì vậy, ufw status báo port bị deny, trong khi port đó vẫn trả lời Internet bình thường. Cơ chế đầy đủ được trình bày trong hướng dẫn về cách Docker bypass ufw khi publish port.

Kiểm tra port nào thực sự đang lắng nghe:

sudo ss -tlnp | grep 8000

Kết quả đúng là một dòng duy nhất bind vào 127.0.0.1:8000. Dòng bind vào 0.0.0.0:8000 nghĩa là vault đang bị expose trực tiếp. Sửa mapping rồi recreate container, vì port binding được cố định khi container được tạo và docker compose restart sẽ không thay đổi nó:

docker compose up -d --force-recreate

Một port khác vẫn xuất hiện trong các tài liệu cũ: 3012, port WebSocket riêng. Vaultwarden đã bỏ hỗ trợ port này từ 1.31.0 vì traffic notification đã chuyển sang port HTTP chính. WEBSOCKET_ENABLEDWEBSOCKET_PORT đã bị bỏ qua từ 1.29.0. Tùy chọn hiện tại là ENABLE_WEBSOCKET, mặc định là true. Nếu firewall hoặc compose file của bạn vẫn mở port 3012, hãy đóng port đó.

Kết thúc TLS tại reverse proxy, không phải trong Rocket

Vaultwarden có thể tự phục vụ TLS (transport layer security) thông qua Rocket, web framework của nó, nhưng project khuyến cáo không làm vậy trong production. TLS tích hợp sẵn của Rocket thiếu hỗ trợ SNI (server name indication) chặt chẽ. Đây cũng là lý do khuyến cáo hardening yêu cầu bạn truy cập instance bằng hostname và không bao giờ dùng bare IP address. Các dải public IP bị scan liên tục. Một vault phản hồi trên IP address sẽ bị phát hiện.

Các phần quan trọng trong server block của nginx:

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 mặc định đặt client_max_body_size ở mức 1 MB. Nếu thiếu dòng đó, việc upload attachment sẽ fail với 413 Request Entity Too Large trong nginx error log, còn Vaultwarden hoàn toàn không ghi log gì. Các header UpgradeConnection chuyển tiếp WebSocket handshake đến /notifications/hub. Nếu bỏ chúng, vault vẫn hoạt động, nhưng các thay đổi sẽ không còn xuất hiện trên những thiết bị khác cho đến khi bạn tự reload trang.

Caddy có cấu hình ngắn hơn và tự lấy certificate:

vw.example.com {
  reverse_proxy 127.0.0.1:8000 {
    header_up X-Real-IP {remote_host}
  }
}

Sau đó khai báo cho Vaultwarden:

DOMAIN=https://vw.example.com
IP_HEADER=X-Real-IP

IP_HEADER đã mặc định là X-Real-IP. Vì vậy, việc cần làm là bảo đảm proxy thực sự đặt header đó. Nếu không, mọi log line và mọi rate limit đăng nhập đều thấy 127.0.0.1, tức là chính proxy. Khi đó, các lần fail của một attacker sẽ bị tính cho mọi user trên instance. Đồng thời đặt DOMAIN thành URL https thực tế, vì Vaultwarden dùng giá trị này để tạo link invitation và password reset. Các security key của WebAuthn cũng được bind với origin đó.

Có một chi tiết nhiều người bỏ qua: kết nối WebSocket truyền session token trong query string dưới dạng /notifications/hub?access_token=[JWT]. Giá trị này sẽ xuất hiện rõ trong proxy access log. Hãy redact parameter access_token trong log format, hoặc bảo đảm các log đó không được ship đến bất kỳ nơi nào bạn không kiểm soát.

Chặn brute force tại endpoint đăng nhập

Rate limit được bật mặc định (LOGIN_RATELIMIT_SECONDS=60, LOGIN_RATELIMIT_MAX_BURST=10). Chúng làm chậm attacker. Chúng không ngăn được attacker. fail2ban thì có thể, nhưng trước tiên Vaultwarden phải ghi log vào một file; mặc định nó không làm việc đó:

LOG_FILE=/data/vaultwarden.log
LOG_LEVEL=info
EXTENDED_LOGGING=true

Một lần đăng nhập thất bại sau đó tạo đúng một dòng. Đây là chuỗi mà filter của bạn phải khớp:

[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.

Ghi filter vào /etc/fail2ban/filter.d/vaultwarden.local:

[INCLUDES]
before = common.conf

[Definition]
failregex = ^.*?Username or password is incorrect\. Try again\. IP: <ADDR>\. Username:.*$
ignoreregex =

Và jail vào /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

Nếu bạn vẫn giữ trang admin, hãy thêm một jail thứ hai có failregex^.*Invalid admin token\. IP: <ADDR>.*$, vì các lần đăng nhập admin thất bại được ghi bằng một message khác và login filter sẽ không bao giờ bắt được chúng. Sau đó kiểm tra cấu hình:

sudo systemctl restart fail2ban
sudo fail2ban-client status vaultwarden

Jail hoạt động sẽ liệt kê log file của bạn tại File list và báo Currently failed: 0. Nhập sai password 3 lần từ một network khác. Counter đó sẽ tăng, sau đó địa chỉ sẽ xuất hiện tại Banned IP list. Nếu counter không tăng, nguyên nhân thường gặp là logpath: giá trị này phải là path của file trên host, không phải path /data/... bên trong container. Nguyên nhân thường gặp thứ hai là thiếu X-Real-IP, khiến mọi lệnh ban đều nhắm vào chính proxy của bạn. Phần còn lại của cấu hình, bao gồm SSH jail mà bạn lẽ ra đã chạy, có trong hướng dẫn fail2ban cho Ubuntu 24.04.

Mật khẩu chính vẫn là toàn bộ hệ thống

Mã hóa phía client nghĩa là mật khẩu chính chính là key. Một mật khẩu chính ngắn trên instance có database đã bị attacker sao chép không được bảo vệ bởi bất kỳ thiết lập nào trong bài này, vì attacker sẽ tấn công bản sao đó offline với tốc độ mà phần cứng của họ cho phép. Không có thiết lập nào trên server có thể kiểm soát máy của attacker.

PASSWORD_ITERATIONS=600000 là số vòng lặp KDF được gửi cho client khi họ tạo account mới. Các account hiện có vẫn giữ giá trị được dùng khi tạo account, nên tăng giá trị này không thay đổi gì đối với những người đã đăng ký từ năm ngoái. Họ phải tự thay đổi trong phần cài đặt bảo mật của web vault; thao tác này sẽ mã hóa lại key của họ. Hãy thông báo cho họ, vì giao diện không hiển thị thông tin này.

Sau đó, bật xác thực hai yếu tố cho từng account. Tính năng này không bảo vệ ciphertext, vì vault key chỉ được tạo từ mật khẩu chính. Tuy nhiên, nó ngăn việc chỉ có mật khẩu bị đánh cắp là đủ để đăng nhập và đồng bộ một bản sao. REQUIRE_DEVICE_EMAIL=true thêm bước xác nhận qua email khi account đăng nhập lần đầu từ một thiết bị chưa được nhận diện.

Sao lưu là điểm dễ sai nhất khi tự host vault

Một tar czf của thư mục dữ liệu, nằm trong thư mục home trên cùng VPS, sẽ vô hiệu hóa mọi bước ở trên. Archive đó chứa db.sqlite3 với ciphertext của mọi người dùng, rsa_key.pem dùng để giả mạo các phiên đăng nhập, và config.json chứa admin token cùng mật khẩu SMTP ở dạng plaintext. Có quyền đọc một file đó cũng tương đương có quyền đọc vault.

Chỉ cần tuân theo 2 quy tắc. Đưa archive ra khỏi máy chủ. Mã hóa archive trước khi truyền đi.

Ngoài ra còn có vấn đề về tính toàn vẹn dữ liệu. Sao chép db.sqlite3 bằng cp khi service đang chạy có thể tạo ra một file đang được ghi dở và không thể mở. Bạn sẽ chỉ phát hiện lỗi này khi restore. Hãy dùng snapshot của chính SQLite:

sqlite3 /vw-data/db.sqlite3 ".backup '/tmp/vw-db-backup.sqlite3'"

Phần restore, là phần hầu như không ai kiểm tra, được trình bày trong hướng dẫn sao lưu và restore Vaultwarden.

Bạn phải tự chịu những gì khi không dùng Bitwarden hosted

Đánh giá thẳng thắn. Dịch vụ hosted của Bitwarden do đội ngũ có công việc toàn thời gian là vận hành dịch vụ này quản lý, có các cuộc audit từ bên thứ ba được công bố và luôn có người trực lúc 3 giờ sáng. Tự host đồng nghĩa với việc bạn phải tự quản lý lịch patch.

Vaultwarden phát hành các bản sửa lỗi bảo mật như các bản release thông thường. Version 1.37.0, phát hành ngày 24 July 2026, là bản hiện tại tính đến August 2026, và ghi chú release yêu cầu người dùng cập nhật sớm nhất có thể. Một instance bạn thiết lập từ một năm trước rồi quên mất vẫn đang chạy code cũ một năm. Tag latest không tự giải quyết vấn đề này: container đang chạy vẫn giữ image mà nó đã khởi động, cho đến khi bạn chạy docker compose pull và tạo lại container. Hãy triển khai unattended upgrades trên Ubuntu cho các package của host, đồng thời đặt lịch nhắc cập nhật container vào thời điểm bạn chắc chắn sẽ đọc.

Kết luận mà người đọc nên rút ra là: phần cryptography ở đây dựa trên thiết kế của Bitwarden và vẫn đủ vững, còn rủi ro vận hành hoàn toàn chuyển sang bạn. Nếu bạn patch hệ thống và backup dữ liệu sang một nơi khác, một instance Vaultwarden trên VPS do bạn kiểm soát là nơi hợp lý để lưu password. Nếu bạn không thể duy trì hai thói quen đó, hãy trả phí cho dịch vụ hosted và dành thời gian cho việc khác. Bản so sánh theo từng tính năng có trong so sánh Vaultwarden với Bitwarden tự host.

Hardening host bên dưới container

Vaultwarden là một process trên máy Linux, và root trên máy đó có thể đọc /vw-data bất kể ứng dụng được cấu hình như thế nào. Chạy container bằng user không có quyền cao với user: "1000:1000" trong file compose, đồng thời đặt quyền sở hữu thư mục dữ liệu cho phù hợp. Gắn mọi thứ mà container không cần ghi ở chế độ chỉ đọc bằng :ro. Sau đó đóng cửa ngõ truy cập: Hardening SSH trên VPS hướng dẫn đăng nhập chỉ bằng key và tắt xác thực bằng password. Đây là cách ngăn kiểu tấn công đơn giản vượt qua tất cả các lớp bảo vệ trên.

FAQ

Có ai đọc được mật khẩu của tôi nếu họ đánh cắp database Vaultwarden không?

Không trực tiếp. Mỗi mục trong vault được mã hóa trên client bằng key tạo từ master password, nên db.sqlite3 chỉ chứa ciphertext. Dữ liệu họ lấy được ngay gồm địa chỉ email của từng account, các thiết lập KDF, metadata đăng nhập và thiết bị, cùng các secret two-factor trong bảng twofactor. Các secret này được lưu không mã hóa vì server phải tính code dự kiến. Họ cũng có thể tấn công ciphertext của vault offline trong thời gian tùy ý. Vì vậy, độ dài master password là yếu tố quyết định kết quả.

Tôi nên dùng ADMIN_TOKEN hay tắt hoàn toàn trang admin?

Nếu có thể, hãy tắt trang này. Hầu hết instance chỉ cần dùng nó một lần để cấu hình SMTP và mời user, sau đó không cần nữa. Để tắt, không đặt ADMIN_TOKEN hoặc DISABLE_ADMIN_TOKEN, xóa mọi key "admin_token" khỏi config.json, rồi tạo lại container. Chỉ xóa biến môi trường là chưa đủ, vì các thiết lập được ghi từ trang admin nằm trong config.json và được ưu tiên. Nếu vẫn giữ trang này, hãy lưu token dưới dạng hash Argon2 được tạo bằng vaultwarden hash thay vì một chuỗi random dạng plaintext, và đặt ADMIN_RATELIMIT_MAX_BURST=3.

ADMIN_TOKEN của tôi đúng nhưng /admin từ chối. Lỗi ở đâu?

Gần như luôn là do nội suy $. Chuỗi PHC của Argon2 chứa nhiều ký tự $. Docker Compose sẽ mở rộng chúng thành biến bên trong block docker-compose.yml environment:, nên container nhận giá trị bị biến đổi dù file của bạn trông vẫn đúng. Trong file compose, hãy nhân đôi mỗi $ thành $$, hoặc chuyển giá trị vào file .env được bọc bằng dấu nháy đơn, nơi không cần escaping. Sau đó tạo lại container, vì restart không áp dụng các thay đổi về environment.

Tôi còn cần mở port 3012 cho notification không?

Không. Hỗ trợ network traffic WebSocket trên port 3012 đã bị xóa trong Vaultwarden 1.31.0 vì notification đã chuyển sang port HTTP chính. WEBSOCKET_ENABLEDWEBSOCKET_PORT cũng đã bị bỏ qua từ 1.29.0. Thiết lập hiện tại là ENABLE_WEBSOCKET, mặc định là true. Hãy đóng port 3012 trong firewall và xóa port này khỏi file compose. Sau đó bảo đảm reverse proxy chuyển tiếp các header UpgradeConnection, vì real-time sync hiện phụ thuộc vào các header đó.