{"record":{"id":"8c56b7b089b66aca","repo":"antiwork/gumroad","slug":"missing-challenge","errorCode":"missing_challenge","errorMessage":"missing_challenge","messagePattern":"missing_challenge","errorType":"error_code","errorClass":"VerificationError","httpStatus":422,"severity":"error","filePath":"app/controllers/logins/passkeys_controller.rb","lineNumber":19,"sourceCode":"# frozen_string_literal: true\n\nclass Logins::PasskeysController < ApplicationController\n  include WebauthnCeremonyVerification\n\n  AUTHENTICATION_ERROR_MESSAGE = \"We couldn't sign you in with that passkey. Please try again or use your password.\"\n\n  skip_before_action :check_suspended\n  skip_before_action :invalidate_session_if_necessary\n\n  def options\n    render json: { success: true, options: build_webauthn_authentication_options }\n  end\n\n  def create\n    Rails.logger.info(\"passkey.authentication.started\")\n\n    challenge = session.delete(AUTHENTICATION_CHALLENGE_SESSION_KEY)\n    raise VerificationError, \"missing_challenge\" if challenge.blank?\n\n    stored_credential = verified_credential(challenge)\n\n    user = stored_credential.user\n    raise VerificationError, \"deleted_user\" if user.deleted?\n\n    stored_credential.save!\n\n    user.remember_me = true\n    sign_in(user)\n    reset_two_factor_auth_login_session\n    merge_guest_cart_with_user_cart\n    refresh_passkey_setup_prompt(user)\n\n    Rails.logger.info(\"passkey.authentication.succeeded user_id=#{user.id} webauthn_credential_id=#{stored_credential.id}\")\n\n    render json: { success: true, redirect_location: login_path_for(user) }\n  rescue VerificationError => e","sourceCodeStart":1,"sourceCodeEnd":37,"githubUrl":"https://github.com/antiwork/gumroad/blob/afeacbd394069a1cbf0c6c50ee8e900925050370/app/controllers/logins/passkeys_controller.rb#L1-L37","documentation":"Raised in `Logins::PasskeysController#create` (logins/passkeys_controller.rb:19) when `session.delete(:webauthn_authentication_challenge)` is blank — the one-time challenge stored by the preceding `options` action (which builds `WebAuthn::Credential.options_for_get`) is gone. WebAuthn ceremonies require the server-issued challenge to be echoed back exactly once; without it, verification cannot proceed and the generic authentication error (422) is returned.","triggerScenarios":"POSTing the assertion (create) without first GETting /logins/passkeys/options in the same session; session cookie lost between options and create (third-party-cookie blocking, Safari ITP, cross-site embed, session expiry/rotation); submitting twice — the first create consumes the challenge via `session.delete`, so a duplicate/double-clicked submit finds nothing; server-side session store flushed by a restart.","commonSituations":"Browsers blocking the session cookie (iframe-embedded checkout or private windows); user leaves the login tab open past session expiry; retry logic re-posting the same assertion; load-balanced environments with inconsistent session storage.","solutions":["Always run the ceremony in order: GET /logins/passkeys/options → navigator.credentials.get with the returned options → POST /logins/passkeys once.","On failure, restart the flow from the options call — never re-post the old assertion; generate a fresh challenge for each attempt.","Ensure cookies for the Gumroad domain are enabled (no ITP/iframe blocking) so the session carrying the challenge survives between the two requests.","If this fires for every user after infra work, check the session store/secret_key_base configuration and stickiness across app servers."],"exampleFix":"// before — submitting an assertion from a stale page (challenge consumed or expired)\nawait fetch(\"/logins/passkeys\", { method: \"POST\", body: credentialJson }); // 422 missing_challenge\n\n// after — fetch fresh options first, then submit once\nconst { options } = await (await fetch(\"/logins/passkeys/options\")).json();\nconst credential = await navigator.credentials.get({ publicKey: publicKeyCredentialRequestOptionsFrom(options) });\nawait fetch(\"/logins/passkeys\", { method: \"POST\", headers: { \"Content-Type\": \"application/json\" }, body: serialize(credential) });","handlingStrategy":"retry","validationCode":"// client: run the full ceremony per attempt — options then create\nconst { options } = await fetchJson(\"/logins/passkeys/options\");\nif (!options.challenge) throw new Error(\"no challenge issued\");\nconst credential = await navigator.credentials.get({ publicKey: toRequestOptions(options) });\nawait postAssertion(credential);","typeGuard":"const hasChallengeStored = (options) =>\n  Boolean(options && options.challenge); // server set session[AUTHENTICATION_CHALLENGE_SESSION_KEY] when issuing these","tryCatchPattern":"// client: on 422, restart from the options call — the old challenge was consumed\ntry { await postAssertion(credential); }\ncatch (e) { const fresh = await getOptions(); await runCeremony(fresh); }","preventionTips":["One ceremony = one options fetch + one create POST; never reuse an assertion.","Guard against double-click submits (disable the button after first POST).","Ensure session cookies work in the embed context (no third-party-cookie blocking).","Check session store stickiness after infra changes."],"tags":["webauthn","passkeys","session","challenge","authentication"],"backgroundTag":"webauthn-challenge-missing","analyzedSha":"afeacbd394069a1cbf0c6c50ee8e900925050370","analyzedAt":"2026-08-21T17:58:52.159Z","schemaVersion":2},"datasetVersion":"2026-08-21T18:17:14.833Z"}