Vaultwarden Authentication Flow: JWT Tokens, PBKDF2 Passwords, and Argon2id Admin Security

Vaultwarden authenticates users via PBKDF2-SHA256 password verification, issues RSA-signed JWT access and refresh tokens, and secures privileged admin access with memory-hard Argon2id hashing.

The Vaultwarden authentication system implements a multi-step flow that handles standard user logins, token refreshes, and administrative access. While user passwords rely on PBKDF2-SHA256 for compatibility with Bitwarden clients, the admin interface leverages modern Argon2id hashing to protect the ADMIN_TOKEN secret.

Login Endpoint and Grant Type Dispatch

All authentication requests enter through POST /connect/token in src/api/identity.rs. The Rocket handler inspects the grant_type parameter to determine which authentication path to execute.

The dispatch logic handles four primary grant types:

  • Password (password) – Standard username/password authentication
  • Refresh Token (refresh_token) – Token renewal without re-entering credentials
  • Client Credentials (client_credentials) – API key-based service authentication
  • Authorization Code (authorization_code) – SSO/OIDC flows when enabled
// src/api/identity.rs#L63-L81
match data.grant_type.as_ref() {
    "password" => { /* calls _password_login */ },
    "refresh_token" => { /* calls _refresh_login */ },
    "client_credentials" => { /* calls _api_key_login */ },
    "authorization_code" => { /* calls _sso_login if SSO enabled */ },
    _ => err!("Invalid grant type"),
}

User Password Verification with PBKDF2-SHA256

Contrary to the admin token implementation, user passwords do not use Argon2id. Instead, Vaultwarden verifies passwords using PBKDF2-SHA256 as implemented in src/db/models/user.rs and src/crypto.rs.

When _password_login processes credentials, it delegates to User::check_valid_password:

pub fn check_valid_password(&self, password: &str) -> bool {
    crypto::verify_password_hash(
        password.as_bytes(),
        &self.salt,
        &self.password_hash,
        self.password_iterations as u32,
    )
}

The underlying PBKDF2 implementation resides in src/crypto.rs, using HMAC-SHA256:

pub fn hash_password(secret: &[u8], salt: &[u8], iterations: u32) -> Vec<u8> {
    let mut out = vec![0u8; OUTPUT_LEN];
    let iterations = NonZeroU32::new(iterations).expect("Iterations can't be zero");
    pbkdf2::derive(DIGEST_ALG, iterations, salt, secret, &mut out);
    out
}

Upon successful verification, the server creates or updates a device record and proceeds to JWT generation.

JWT Access and Refresh Token Generation

After credential verification, src/auth.rs generates an RSA-signed JWT pair. The AuthTokens::new function creates both tokens using a lazily loaded RSA private key initialized at startup via initialize_keys.

Access tokens (LoginJwtClaims) contain the user ID, device ID, security stamp, and expiration timestamp:

pub fn encode_jwt<T: Serialize>(claims: &T) -> String {
    jsonwebtoken::encode(&JWT_HEADER, claims, PRIVATE_RSA_KEY.wait())
        .expect("Error encoding jwt")
}

Refresh tokens (RefreshJwtClaims) include the device identifier and vary in validity based on device type—mobile devices use MOBILE_REFRESH_VALIDITY while desktop clients use DEFAULT_REFRESH_VALIDITY:

let refresh_claims = RefreshJwtClaims {
    nbf: time_now.timestamp(),
    exp: (time_now + validity).timestamp(),
    iss: JWT_LOGIN_ISSUER.to_string(),
    sub,
    device_token: device.refresh_token.clone(),
    token: None,
};

Token Refresh Flow

When access tokens expire, clients present refresh tokens via the same /connect/token endpoint using grant_type=refresh_token. The _refresh_login function in src/api/identity.rs decodes the token and validates the associated device via Device::find_by_refresh_token. Upon validation, it issues a fresh JWT pair.

The implementation includes legacy compatibility logic that falls back to raw Base64 string handling for old-style refresh tokens.

Two-Factor Authentication Integration

Following successful password verification, the system checks for enabled 2FA methods via TwoFactor::find_by_user. If 2FA is configured, the login response includes "twoFactorProviders" requiring the client to complete secondary verification at /two-factor/... endpoints before receiving final tokens.

Admin Token Authentication with Argon2id

The only place Vaultwarden utilizes Argon2id is for admin token validation. When accessing the web admin interface, the system validates the ADMIN_TOKEN using the _validate_token function in src/api/admin.rs.

The validation supports both plain text secrets and Argon2id PHC hashes:

fn _validate_token(token: &str) -> bool {
    match CONFIG.admin_token().as_ref() {
        Some(t) if t.starts_with("$argon2") => {
            use argon2::password_hash::PasswordVerifier;
            match argon2::password_hash::PasswordHash::new(t) {
                Ok(h) => argon2::Argon2::default()
                            .verify_password(token.trim().as_ref(), &h)
                            .is_ok(),
                Err(e) => { error!("Invalid Argon2 PHC: {e}"); false }
            }
        },
        Some(t) => crate::crypto::ct_eq(t.trim(), token.trim()), // Constant-time compare
        None => false,
    }
}

This memory-hard Argon2id implementation provides brute-force resistance for the privileged admin secret, unlike the PBKDF2-based user authentication.

Generating Argon2id Admin Tokens

Administrators generate Argon2id hashes using the built-in hash sub-command in src/main.rs. This creates a PHC-formatted string suitable for the ADMIN_TOKEN environment variable:

./vaultwarden hash
Password: ********
Confirm Password: ********

# Output:

ADMIN_TOKEN='$argon2id$v=19$m=65540,t=3,p=4$ZzJ2b2JzZ3N0YW5kZW...'

The implementation uses Argon2id with version 0x13 and configurable parameters (defaulting to "owasp" or "bitwarden" presets):

let argon2 = Argon2::new(Argon2id, V0x13, argon2_params.build().unwrap());
let salt = SaltString::encode_b64(&crypto::get_random_bytes::<32>()).unwrap();
let password_hash = argon2.hash_password(password.as_bytes(), &salt)?;
println!("ADMIN_TOKEN='{password_hash}'");

Summary

  • User authentication uses PBKDF2-SHA256 password hashing in src/crypto.rs, not Argon2id.
  • Login requests enter via src/api/identity.rs, which dispatches by grant type to handle passwords, refresh tokens, or SSO.
  • JWT tokens are RSA-signed in src/auth.rs, with separate access and refresh token lifecycles.
  • Admin security utilizes memory-hard Argon2id hashing via src/api/admin.rs for the ADMIN_TOKEN.
  • Use the vaultwarden hash command to generate secure Argon2id admin tokens.

Frequently Asked Questions

Does Vaultwarden use Argon2id for user password hashing?

No. User passwords are hashed using PBKDF2-SHA256 as implemented in src/crypto.rs, maintaining compatibility with the official Bitwarden server protocol. Argon2id is reserved exclusively for the admin token (ADMIN_TOKEN) to provide enhanced protection for privileged access.

How do refresh tokens work in Vaultwarden?

Refresh tokens are JWTs containing device identifiers stored in the RefreshJwtClaims struct. When a client presents a refresh token to /connect/token, the server validates it against the device record in the database and issues a new access token. Mobile and desktop clients have different validity periods defined by MOBILE_REFRESH_VALIDITY and DEFAULT_REFRESH_VALIDITY constants.

Where is the admin token validated in the codebase?

Admin token validation occurs in src/api/admin.rs within the _validate_token function. This function checks if the configured token starts with $argon2 to detect Argon2id PHC format, otherwise falling back to constant-time string comparison using crypto::ct_eq.

What cryptographic keys sign the JWT tokens?

Vaultwarden uses RSA private keys for JWT signing. The key pair is lazily loaded at startup via initialize_keys in src/auth.rs and stored in the static PRIVATE_RSA_KEY. The corresponding public key validates incoming access tokens in the Authorization header middleware.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →