Skip to content

fix(one-tap): verify Google ID token nonce in callback - #11247

Open
waleed-mirza wants to merge 1 commit into
better-auth:mainfrom
waleed-mirza:fix/one-tap-nonce-verification
Open

fix(one-tap): verify Google ID token nonce in callback#11247
waleed-mirza wants to merge 1 commit into
better-auth:mainfrom
waleed-mirza:fix/one-tap-nonce-verification

Conversation

@waleed-mirza

@waleed-mirza waleed-mirza commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Summary

  • /one-tap/callback now accepts an optional nonce in the request body and forwards it to verifyGoogleIdToken, which compares it against the ID token's nonce claim
  • oneTapClient now sends opts.nonce in the callback POST body (previously it only passed the nonce to google.accounts.id.initialize, so the server never verified it — a captured token stayed replayable for its lifetime)
  • Documented the nonce option in the One Tap plugin docs

Closes #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 typecheck passes
  • biome/format/spell hooks pass
  • Changeset added (patch)

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/callback now accepts an optional nonce in the request body and forwards it to verifyGoogleIdToken, which checks it against the token's nonce claim.
  • oneTapClient now sends opts.nonce in the callback POST body; previously it only passed the nonce to google.accounts.id.initialize, so the server never verified it.
  • Tokens with a mismatched or missing nonce claim are rejected with 400 when a nonce is provided; requests without a nonce still work for backward compatibility.
  • Documents the nonce option in the One Tap plugin docs.

Written for commit 5395b4a. Summary will update on new commits.

Review in cubic

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@waleed-mirza
waleed-mirza requested review from a team as code owners September 10, 2026 17:17
@waleed-mirza
waleed-mirza requested review from Bekacru and gustavovalverde and removed request for a team September 10, 2026 17:17
@vercel

vercel Bot commented Sep 10, 2026

Copy link
Copy Markdown

@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.

@better-release better-release Bot added oauth Social providers, OAuth flows, generic-oauth, oauth-proxy docs Documentation, demos labels Sep 10, 2026
@greptile-apps

greptile-apps Bot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds nonce forwarding and comparison to the Google One Tap callback, documents the option, and adds matching and mismatch tests. However, the expected nonce remains controlled by the callback caller rather than trusted server-side state, so the intended replay protection can be bypassed.

  • Sends the One Tap action nonce in the callback request.
  • Passes the request-body nonce to Google ID-token verification.
  • Adds tests for matching, mismatched, missing, and omitted nonces.
  • Documents per-attempt nonce usage.

Confidence Score: 3/5

This PR is not safe to merge as a replay-protection fix until the expected nonce is sourced from trusted per-attempt server state and cannot be bypassed by omission.

The callback accepts both the ID token and its expected nonce from the caller, while omission disables the nonce comparison, so possession of a captured token remains sufficient to obtain a session.

Files Needing Attention: packages/better-auth/src/plugins/one-tap/index.ts, packages/better-auth/src/plugins/one-tap/client.ts, packages/better-auth/src/plugins/one-tap/one-tap.test.ts

Security Review

The nonce check does not provide replay protection because the callback caller supplies the expected nonce and may omit it. A replayed token can therefore still be exchanged for a session without proving that it belongs to a server-tracked sign-in attempt.

Fix all with Greploop Fix All in Cursor Fix All in Codex Fix All in Claude Code

Reviews (1): Last reviewed commit: "fix(one-tap): verify Google ID token non..." | Re-trigger Greptile

Comment on lines 132 to 136
const payload = (await verifyGoogleIdToken({
token: idToken,
audience,
nonce,
})) as Partial<GoogleProfile> | null;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security 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.

Fix in Cursor Fix in Codex Fix in Claude Code

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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,

@cubic-dev-ai cubic-dev-ai Bot Sep 10, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Fix with cubic

@waleed-mirza

Copy link
Copy Markdown
Contributor Author

Good point from the AI reviewers — worth addressing explicitly.

They're correct that a client-supplied nonce only verifies self-consistency (token claim == body value), which an attacker holding the token can trivially satisfy, and omitting the field skips the check. Full replay protection requires a server-issued nonce: server generates and stores it (cookie or verification row), client passes it to Google, server compares on callback and consumes it — the same pattern used in the OIDC discovery-provider flow.

This PR implements the scope accepted in #10926 ("the endpoint accepts an expected nonce and forwards it to verifyGoogleIdToken", labeled target: patch). It closes the gap where the client option suggested nonce protection existed while the server never verified it, and it keeps the field optional for backward compatibility.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

docs Documentation, demos oauth Social providers, OAuth flows, generic-oauth, oauth-proxy

Projects

None yet

Development

Successfully merging this pull request may close these issues.

one-tap: /one-tap/callback never passes a nonce to verifyGoogleIdToken

1 participant