fix(one-tap): verify Google ID token nonce in callback - #11247
fix(one-tap): verify Google ID token nonce in callback#11247waleed-mirza wants to merge 1 commit into
Conversation
Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
|
@waleed-mirza is attempting to deploy a commit to the better-auth Team on Vercel. A member of the Team first needs to authorize it. |
|
| const payload = (await verifyGoogleIdToken({ | ||
| token: idToken, | ||
| audience, | ||
| nonce, | ||
| })) as Partial<GoogleProfile> | null; |
There was a problem hiding this comment.
Caller Controls Expected Nonce
The expected nonce comes from the same unauthenticated request as the ID token, so it does not bind the token to a server-known sign-in attempt. An attacker replaying a captured token can submit its readable nonce claim—or omit nonce entirely, since the field remains optional—and receive a session after verification succeeds. The server must obtain and require the expected nonce from trusted per-attempt state rather than accepting it from the callback body.
How this was verified: The public callback forwards its optional request-body nonce directly into a verifier that only compares it with the token claim when the supplied value is truthy.
There was a problem hiding this comment.
1 issue found across 5 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="packages/better-auth/src/plugins/one-tap/index.ts">
<violation number="1" location="packages/better-auth/src/plugins/one-tap/index.ts:135">
P1: When a nonce is configured, this still accepts replays because the expected value comes from the same untrusted callback body as `idToken`. A captured JWT reveals its `nonce` claim, so an attacker can submit the token with that value, or omit the optional field; bind and consume a server-issued nonce instead of trusting `ctx.body.nonce`.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Fix all with cubic | Re-trigger cubic
| const payload = (await verifyGoogleIdToken({ | ||
| token: idToken, | ||
| audience, | ||
| nonce, |
There was a problem hiding this comment.
P1: When a nonce is configured, this still accepts replays because the expected value comes from the same untrusted callback body as idToken. A captured JWT reveals its nonce claim, so an attacker can submit the token with that value, or omit the optional field; bind and consume a server-issued nonce instead of trusting ctx.body.nonce.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/better-auth/src/plugins/one-tap/index.ts, line 135:
<comment>When a nonce is configured, this still accepts replays because the expected value comes from the same untrusted callback body as `idToken`. A captured JWT reveals its `nonce` claim, so an attacker can submit the token with that value, or omit the optional field; bind and consume a server-issued nonce instead of trusting `ctx.body.nonce`.</comment>
<file context>
@@ -119,6 +132,7 @@ export const oneTap = (options?: OneTapOptions | undefined) =>
const payload = (await verifyGoogleIdToken({
token: idToken,
audience,
+ nonce,
})) as Partial<GoogleProfile> | null;
if (!payload) {
</file context>
|
Good point from the AI reviewers — worth addressing explicitly. They're correct that a client-supplied This PR implements the scope accepted in #10926 ("the endpoint accepts an expected nonce and forwards it to Happy to take this further if you'd prefer — the server-bound design would need a nonce-issuing endpoint (or piggyback on an existing mechanism) and one-time consumption, which feels like a larger change than patch scope. Let me know whether to expand this PR or land this and follow up separately. |
Summary
/one-tap/callbacknow accepts an optionalnoncein the request body and forwards it toverifyGoogleIdToken, which compares it against the ID token'snonceclaimoneTapClientnow sendsopts.noncein the callback POST body (previously it only passed the nonce togoogle.accounts.id.initialize, so the server never verified it — a captured token stayed replayable for its lifetime)nonceoption in the One Tap plugin docsCloses #10926
Test plan
pnpm vitest packages/better-auth/src/plugins/one-tap --run— 27 tests pass, including 4 new nonce cases (matching nonce accepted; mismatched/missing claim rejected with 400; no nonce still accepted for backward compatibility)pnpm typecheckpassespatch)Generated with Devin
Summary by cubic
Verifies the Google One Tap nonce on the server so a captured ID token can no longer be replayed for its lifetime. Closes #10926.
/one-tap/callbacknow accepts an optionalnoncein the request body and forwards it toverifyGoogleIdToken, which checks it against the token'snonceclaim.oneTapClientnow sendsopts.noncein the callback POST body; previously it only passed the nonce togoogle.accounts.id.initialize, so the server never verified it.nonceoption in the One Tap plugin docs.Written for commit 5395b4a. Summary will update on new commits.