Skip to content

feat(otp): switch one_time_tokens table to source of truth - #2788

Open
annabkr wants to merge 14 commits into
annabaker/auth-1553-ott-query-helpersfrom
annabaker/auth-1553-switch-one_time_tokens-table-to-source-of-truth
Open

feat(otp): switch one_time_tokens table to source of truth#2788
annabkr wants to merge 14 commits into
annabaker/auth-1553-ott-query-helpersfrom
annabaker/auth-1553-switch-one_time_tokens-table-to-source-of-truth

Conversation

@annabkr

@annabkr annabkr commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

What kind of change does this PR introduce?

Feat

What is the current behavior?

We dual-write token data to the one_time_tokens and users table, but read token data from them inconsistently.

What is the new behavior?

When a flag GOTRUE_EXPERIMENTAL_ENABLE_OTT_AS_SOURCE_OF_TRUTH is enabled, we treat one_time_tokens as the source of truth for token data, and only lookup the user to validate eligibility and confirm identifier binding.

Additional context

I've intentionally split out the Twilio Verify flow + Test OTP handling into a separate PR, but will wait until both are merged to release.

Closes AUTH-1553.

@annabkr
annabkr changed the base branch from master to annabaker/auth-1572-start-writing-to-one_time_tokensexpiresat September 4, 2026 19:53
@annabkr
annabkr force-pushed the annabaker/auth-1553-switch-one_time_tokens-table-to-source-of-truth branch from 2ed1e5b to 0665647 Compare September 9, 2026 16:14
@annabkr
annabkr changed the base branch from annabaker/auth-1572-start-writing-to-one_time_tokensexpiresat to annabaker/auth-1553-ott-query-helpers September 9, 2026 16:14
@annabkr
annabkr force-pushed the annabaker/auth-1553-switch-one_time_tokens-table-to-source-of-truth branch from 0665647 to 8bf17f6 Compare September 9, 2026 16:20
@annabkr
annabkr force-pushed the annabaker/auth-1553-ott-query-helpers branch 2 times, most recently from 7577774 to fc7f405 Compare September 9, 2026 19:14
@annabkr
annabkr force-pushed the annabaker/auth-1553-switch-one_time_tokens-table-to-source-of-truth branch from 8bf17f6 to 0820b2b Compare September 9, 2026 19:19
Remove the phone-provider branches from the one_time_tokens verify path so
this PR covers only the row-based flow. In OTT mode a test OTP or Twilio
Verify code now misses the hash lookup and is rejected. A follow-up PR
stacked on this branch restores both.
@annabkr
annabkr force-pushed the annabaker/auth-1553-switch-one_time_tokens-table-to-source-of-truth branch from 31b4e74 to 6d15a97 Compare September 10, 2026 14:50
// NOTE: Test OTPs and Twilio Verify are not handled yet on this path; a follow-up PR will add them.
func verifyUserAndTokenFromOTT(conn *storage.Connection, params *VerifyParams, aud string) (*models.User, error) {

// TODO AUTH-1553: Add support for test OTPs and Twilio Verify on this path.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Separating this out to make it easier to review, but they'll be merged together.


// IsExpired treats nil ExpiresAt as expired. This is a security measure to avoid accidentally treating a token with no expiration as valid.
func (o OneTimeToken) IsExpired() bool {
return o.ExpiresAt == nil || time.Now().After(*o.ExpiresAt)

@annabkr annabkr Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

We have two options here:

  1. Treat a nil expiresAt as expired.
  2. Treat a nil expiresAt as non-expired.

expiresAt will be nil for all OTTs created before expiresAt was written to.

We can reduce the likelihood by staggering the release of these changes + the write-path changes so the OTTs without expiries age out, but eventually we plan to make this path on by default. So, if a self-hosted customer doesn't upgrade until we've made it the default, it would cause all of their recently issued tokens to expire.

I went this direction because having to re-request a OTT seems like a minor inconvenience, but I wanted to point that out so we can discuss if there are any concerns.

// columns. A lookup miss is rejected as an expired or invalid token.
//
// NOTE: Test OTPs and Twilio Verify are not handled yet on this path; a follow-up PR will add them.
func verifyUserAndTokenFromOTT(conn *storage.Connection, params *VerifyParams, aud string) (*models.User, error) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The summary is that we:

  1. Verify the OTT by looking it up and checking if it's expired
  2. Resolve the generic type=email request param type to the actual token type for post-verification steps
  3. Find the user by the OTT's user ID
  4. Ensure that the user associated with the OTT is eligible to verify it, and validate identifier binding

@annabkr
annabkr marked this pull request as ready for review September 10, 2026 20:39
@annabkr
annabkr requested a review from a team as a code owner September 10, 2026 20:39
return nil, apierrors.NewForbiddenError(apierrors.ErrorCodeOTPExpired, "Token has expired or is invalid").WithInternalMessage("unknown verification type")
}

ott, err := models.FindOneTimeTokenWithPKCEFallback(conn, params.TokenHash, tokenTypes...)

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.

🟠 Severity: HIGH

The new fallback accepts a plain OTP whose stored email-change hash is pkce_-prefixed, but emailChangeVerify cannot match or clear that prefixed row. Replaying the same attacker-supplied OTP then passes with status already single and reaches the email-change commit, bypassing the required second confirmation.
Helpful? Add 👍 / 👎

💡 Fix Suggestion

Suggestion: After the successful OTT lookup at line 52, normalize params.TokenHash to match the actual stored token hash before returning. When FindOneTimeTokenWithPKCEFallback resolves a PKCE-prefixed row (e.g. stored as pkce_<hash> but looked up by plain <hash>), the returned ott.TokenHash differs from params.TokenHash. The downstream emailChangeVerify function in verify.go then calls FindOneTimeToken(tx, params.TokenHash, ...) with the original plain hash, which finds no row — so neither currentOTT nor newOTT is set, neither ClearOneTimeTokenForUser branch fires, and the PKCE-prefixed token row remains in the database. EmailChangeConfirmStatus is still promoted to singleConfirmation, so a replay of the same OTP bypasses the zeroConfirmation guard and commits the email change without a second confirmation.

Fix: In verifyOneTimeToken (verify_ott.go), insert the following normalization block immediately after the error checks for FindOneTimeTokenWithPKCEFallback (after line 57, before the ott.IsExpired() check):

// If the token was matched via its PKCE-prefixed hash, normalise params.TokenHash
// to the actual stored value so that downstream operations (e.g. emailChangeVerify)
// can find and clear the correct row from the one_time_tokens table.
if ott.TokenHash != params.TokenHash {
    params.TokenHash = ott.TokenHash
}

Because params is passed as a pointer, this update propagates to emailChangeVerify, which will then call FindOneTimeToken(tx, "pkce_<hash>", ...), find the correct row, enter the clearing branch, and delete the token — preventing the replay attack.

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

Nicely done! Nothing stood out for me as blockers. I left one question but it's not a blocker.

I'm not as familiar with this part of the codebase so you might choose to wait for another review from someone else or not. I trust your discretion.

return mismatch.WithInternalMessage("user email does not match")
}
default: // Signup, Invite, Recovery, MagicLink
if params.Email == "" || !strings.EqualFold(user.GetEmail(), params.Email) {

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.

question: is user.GetEmail() a safe fallback?

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants