{"record":{"id":"a4c38e6b392bab28","repo":"bitwarden/server","slug":"credential-not-found","errorCode":null,"errorMessage":"Credential not found.","messagePattern":"Credential not found\\.","errorType":"exception","errorClass":"NotFoundException","httpStatus":404,"severity":"error","filePath":"src/Api/Auth/Controllers/WebAuthnController.cs","lineNumber":165,"sourceCode":"        }\n\n        // assign new keys to credential\n        credential.EncryptedUserKey = model.EncryptedUserKey;\n        credential.EncryptedPrivateKey = model.EncryptedPrivateKey;\n        credential.EncryptedPublicKey = model.EncryptedPublicKey;\n\n        await _credentialRepository.UpdateAsync(credential);\n    }\n\n    [Authorize(Policies.Web)]\n    [HttpPost(\"{id}/delete\")]\n    public async Task Delete(Guid id, [FromBody] SecretVerificationRequestModel model)\n    {\n        var user = await VerifyUserAsync(model);\n        var credential = await _credentialRepository.GetByIdAsync(id, user.Id);\n        if (credential == null)\n        {\n            throw new NotFoundException(\"Credential not found.\");\n        }\n\n        await _credentialRepository.DeleteAsync(credential);\n    }\n\n    private async Task<Core.Entities.User> GetUserAsync()\n    {\n        var user = await _userService.GetUserByPrincipalAsync(User);\n        if (user == null)\n        {\n            throw new UnauthorizedAccessException();\n        }\n        return user;\n    }\n\n    private async Task<Core.Entities.User> VerifyUserAsync(SecretVerificationRequestModel model)\n    {\n        var user = await GetUserAsync();","sourceCodeStart":147,"sourceCodeEnd":183,"githubUrl":"https://github.com/bitwarden/server/blob/e93b962371d80964556f5590c6615f5160a437a1/src/Api/Auth/Controllers/WebAuthnController.cs#L147-L183","documentation":"Thrown (HTTP 404) by DELETE /webauthn/{id}/delete when _credentialRepository.GetByIdAsync(id, user.Id) returns null. The lookup is scoped by both credential id and the authenticated user id, so an id that belongs to a different user also yields 404, not 403.","triggerScenarios":"Client submits a credential id that was already deleted; the id belongs to another user; a stale id from an out-of-sync UI list; a concurrent delete from another session/device between listing and deleting.","commonSituations":"Credential list not refreshed after a delete on another device; user pasted an old id; race condition between two clients deleting the same passkey.","solutions":["Refresh the WebAuthn credential list and retry with a current id.","Treat a 404 on this endpoint as an idempotent success in the UI (the credential is already gone).","Confirm the id originates from the current user's own credential list."],"exampleFix":"// before: surface 404 as an error to the user\ncatch (ex) { showError('Delete failed'); }\n\n// after: treat 404 as already-deleted success\nif (resp.status === 404) { markCredentialRemoved(id); return; }","handlingStrategy":"validation","validationCode":"// Client-side: confirm the id is still in the user's credential list before issuing the delete.\nconst current = await getCredentialList();\nif (!current.some(c => c.id === deleteId)) { markRemoved(deleteId); return; }","typeGuard":null,"tryCatchPattern":"// Treat 404 from this endpoint as an idempotent success.\ntry {\n  await del(`/webauthn/${id}/delete`, body);\n} catch (e) {\n  if (e.status === 404) { markCredentialRemoved(id); return; }\n  throw e;\n}","preventionTips":["Refresh the credential list after any delete before offering further deletes.","Make delete idempotent in the UI by treating 404 as success.","Guard against concurrent deletes with optimistic locking or a post-action refresh."],"tags":["webauthn","passkey","not-found","delete","http-404"],"backgroundTag":null,"analyzedSha":"e93b962371d80964556f5590c6615f5160a437a1","analyzedAt":"2026-08-13T14:22:19.382Z","schemaVersion":2},"datasetVersion":"2026-08-13T19:17:28.613Z"}