Freika/dawarich · error · Auth::VerifyOtpChallengeToken::InvalidToken
user not found
Error message
user not found
What it means
Raised by Auth::VerifyOtpChallengeToken#call when the JWT is fully valid (signature, purpose, jti, freshness) but User.find_by(id: decoded['user_id']) returns nil. The token references a user that no longer exists in the database — typically a deleted account or a token minted against a different database/environment.
Source
Thrown at app/services/auth/verify_otp_challenge_token.rb:29
@token = token
end
def call
raise InvalidToken, 'blank token' if @token.blank?
decoded, = JWT.decode(@token, Auth::InternalTokenSecret.call, true, algorithm: 'HS256')
raise InvalidToken, 'wrong purpose' unless decoded['purpose'] == 'otp_challenge'
raise InvalidToken, 'missing jti' if decoded['jti'].blank?
if decoded['iat'].present? &&
(Time.now.to_i - decoded['iat'].to_i) > Auth::IssueOtpChallengeToken::TTL.to_i
raise InvalidToken, 'token too old'
end
raise TokenReplayed, 'token already consumed' if token_consumed?(decoded['jti'])
user = User.find_by(id: decoded['user_id'])
raise InvalidToken, 'user not found' unless user
@jti = decoded['jti']
user
rescue JWT::DecodeError => e
raise InvalidToken, e.message
end
def mark_consumed!
return false if @jti.blank?
Rails.cache.write(
"#{CONSUMED_KEY_PREFIX}#{@jti}",
true,
expires_in: Auth::IssueOtpChallengeToken::TTL,
unless_exist: true
)
end
View on GitHub (pinned to 97fad417c5)
Solutions
- Treat it as terminal for that token: clear the client-side challenge and ask the user to restart authentication (account no longer exists).
- If it happens after a database restore/reset, note that in-flight tokens are invalidated by design — let them age out.
- If it appears in production for real users, check whether an account-deletion job ran and whether other flows reference deleted users.
- Ensure each environment has its own Auth::InternalTokenSecret so tokens cannot cross environments.
Example fix
# before
rescue Auth::VerifyOtpChallengeToken::InvalidToken => e
render json: { error: e.message }, status: :unauthorized # opaque 'user not found'
# after: translate to user-facing restart
rescue Auth::VerifyOtpChallengeToken::InvalidToken => e
if e.message == 'user not found'
render json: { error: 'Account not found. Please sign up again.' }, status: :not_found
else
render json: { error: e.message }, status: :unauthorized
end Defensive patterns
Strategy: try-catch
Try / catch
begin
user = Auth::VerifyOtpChallengeToken.new(token).call
rescue Auth::VerifyOtpChallengeToken::InvalidToken => e
if e.message == 'user not found'
render json: { error: 'Account no longer exists' }, status: :not_found
else
render json: { error: e.message }, status: :unauthorized
end
end Prevention
- Run account deletion with awareness of in-flight auth flows (or accept these errors as expected noise right after deletions).
- Give each environment its own Auth::InternalTokenSecret so tokens cannot verify across databases.
- Alert if this error spikes without corresponding deletions — it can indicate env mismatch.
When it happens
Trigger: User requested an OTP, then their account was deleted (GDPR erasure, admin cleanup) before submitting the code; a token from a staging environment replayed against production whose user IDs differ; test fixtures that sign tokens for user IDs that were never created.
Common situations: Account deletion mid-flow, database resets/restores while challenges are in flight, shared secrets across environments (Auth::InternalTokenSecret identical in staging and prod) letting cross-environment tokens verify, seeds changed between issue and verify.
Related errors
AI-assisted analysis of Freika/dawarich@97fad417c5 (2026-08-21).
Data as JSON: /api/errors/d3c4b4c5cbe56e1f.
Report an issue: GitHub.