bitwarden/server · error · BadRequestException

The token associated with your request is invalid or has exp

Error message

The token associated with your request is invalid or has expired. A valid token is required to continue.

What it means

Thrown (HTTP 400) by PUT /webauthn when the WebAuthn assertion-options token for the UpdateCredential flow fails. The token is ASP.NET data-protection-encrypted and scoped to WebAuthnLoginAssertionOptionsScope.UpdateKeySet; it is rejected if Unprotect throws, TokenIsValid(UpdateKeySet) is false, or the tokenable carries no Options payload. This flow rotates a user's encryption keys via a PRf-capable passkey, so the token must be freshly issued for that exact scope.

Source

Thrown at src/Api/Auth/Controllers/WebAuthnController.cs:140

    private async Task ValidateIfUserCanUsePasskeyLogin(Guid userId)
    {
        var requireSsoPolicyRequirement = await _policyRequirementQuery.GetAsyncVNext<RequireSsoPolicyRequirement>(userId);

        if (!requireSsoPolicyRequirement.CanUsePasskeyLogin)
        {
            throw new BadRequestException("Passkeys cannot be created for your account. SSO login is required.");
        }
    }

    [Authorize(Policies.Application)]
    [HttpPut()]
    public async Task UpdateCredential([FromBody] WebAuthnLoginCredentialUpdateRequestModel model)
    {
        var tokenable = _assertionOptionsDataProtector.Unprotect(model.Token);
        if (!tokenable.TokenIsValid(WebAuthnLoginAssertionOptionsScope.UpdateKeySet) || tokenable.Options == null)
        {
            throw new BadRequestException("The token associated with your request is invalid or has expired. A valid token is required to continue.");
        }

        var (_, credential) = await _assertWebAuthnLoginCredentialCommand.AssertWebAuthnLoginCredential(tokenable.Options, model.DeviceResponse);
        if (credential == null || credential.SupportsPrf != true)
        {
            throw new BadRequestException("Unable to update credential.");
        }

        // assign new keys to credential
        credential.EncryptedUserKey = model.EncryptedUserKey;
        credential.EncryptedPrivateKey = model.EncryptedPrivateKey;
        credential.EncryptedPublicKey = model.EncryptedPublicKey;

        await _credentialRepository.UpdateAsync(credential);
    }

    [Authorize(Policies.Web)]
    [HttpPost("{id}/delete")]

View on GitHub (pinned to e93b962371)

Solutions

  1. Re-request assertion options for the UpdateKeySet scope and submit the freshly returned token to UpdateCredential immediately.
  2. Persist and share ASP.NET data-protection keys across all server instances (Azure Blob/Redis/EFS key ring) so any node can unprotect tokens any node issued.
  3. Confirm the client forwards the exact token string returned by the options endpoint with no truncation or URL-encoding damage.
  4. Verify server clocks are synced so token expiry windows are not prematurely crossed.

Example fix

// before: reusing a login-scoped token
updateCred({ token: loginToken, deviceResponse, ... }); // -> 400 invalid/expired

// after: request the update-key-set scoped token first
var opts = await post('/webauthn/assertion-options', { scope: 'UpdateKeySet' });
await put('/webauthn', { token: opts.token, deviceResponse, encryptedUserKey, encryptedPrivateKey, encryptedPublicKey });
Defensive patterns

Strategy: validation

Validate before calling

// Client-side: ensure a token was issued for the correct scope and is fresh before calling UpdateCredential.
if (!assertionOptionsToken || scopeUsedToObtainIt !== 'UpdateKeySet') {
  const opts = await requestAssertionOptions({ scope: 'UpdateKeySet' });
  assertionOptionsToken = opts.token;
}
if (Date.now() - tokenIssuedAt > TOKEN_TTL_MS) {
  /* re-request before submit */
}

Try / catch

// HTTP client: catch the 400 and prompt re-issuance of the update-key-set token.
try {
  await put('/webauthn', payload);
} catch (e) {
  if (e.isBadRequest && /invalid or has expired/i.test(e.message)) {
    await refreshUpdateKeySetToken(); // re-request options then retry once
  } else { throw e; }
}

Prevention

When it happens

Trigger: Submitting a token minted for a different scope (e.g. login) to the UpdateCredential endpoint; replaying an already-consumed token; a token past its lifetime; a data-protection key mismatch where the server that issued the token differs from the server that unprotects it (unshared key ring).

Common situations: Multi-node deployment without persisted/shared data-protection keys (each node encrypts with its own key); client reused a login token instead of requesting UpdateKeySet options; long delay between requesting options and submitting; redeploy onto a new host that lost ephemeral keys.

Related errors


AI-assisted analysis of bitwarden/server@e93b962371 (2026-08-13). Data as JSON: /api/errors/e6c6bfd05db8cc5b. Report an issue: GitHub.