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

> Explore Vaultwardens authentication flow with JWT tokens, PBKDF2 passwords, and Argon2id admin security. Understand how user access and admin protection work.

- Repository: [Daniel García/vaultwarden](https://github.com/dani-garcia/vaultwarden)
- Tags: internals
- Published: 2026-03-07

---

**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`](https://github.com/dani-garcia/vaultwarden/blob/main/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

```rust
// 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`](https://github.com/dani-garcia/vaultwarden/blob/main/src/db/models/user.rs)** and **[`src/crypto.rs`](https://github.com/dani-garcia/vaultwarden/blob/main/src/crypto.rs)**.

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

```rust
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`](https://github.com/dani-garcia/vaultwarden/blob/main/src/crypto.rs)**, using HMAC-SHA256:

```rust
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`](https://github.com/dani-garcia/vaultwarden/blob/main/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:

```rust
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`:

```rust
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`](https://github.com/dani-garcia/vaultwarden/blob/main/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`](https://github.com/dani-garcia/vaultwarden/blob/main/src/api/admin.rs)**.

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

```rust
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`](https://github.com/dani-garcia/vaultwarden/blob/main/src/main.rs)**. This creates a PHC-formatted string suitable for the `ADMIN_TOKEN` environment variable:

```bash
./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):

```rust
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`](https://github.com/dani-garcia/vaultwarden/blob/main/src/crypto.rs)**, not Argon2id.
- **Login requests** enter via **[`src/api/identity.rs`](https://github.com/dani-garcia/vaultwarden/blob/main/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`](https://github.com/dani-garcia/vaultwarden/blob/main/src/auth.rs)**, with separate access and refresh token lifecycles.
- **Admin security** utilizes memory-hard Argon2id hashing via **[`src/api/admin.rs`](https://github.com/dani-garcia/vaultwarden/blob/main/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`](https://github.com/dani-garcia/vaultwarden/blob/main/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`](https://github.com/dani-garcia/vaultwarden/blob/main/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`](https://github.com/dani-garcia/vaultwarden/blob/main/src/auth.rs)** and stored in the static `PRIVATE_RSA_KEY`. The corresponding public key validates incoming access tokens in the `Authorization` header middleware.