antiwork/gumroad · error · VerificationError
missing_challenge
missing_challenge
Error message
missing_challenge
What it means
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.
Source
Thrown at app/controllers/logins/passkeys_controller.rb:19
# frozen_string_literal: true
class Logins::PasskeysController < ApplicationController
include WebauthnCeremonyVerification
AUTHENTICATION_ERROR_MESSAGE = "We couldn't sign you in with that passkey. Please try again or use your password."
skip_before_action :check_suspended
skip_before_action :invalidate_session_if_necessary
def options
render json: { success: true, options: build_webauthn_authentication_options }
end
def create
Rails.logger.info("passkey.authentication.started")
challenge = session.delete(AUTHENTICATION_CHALLENGE_SESSION_KEY)
raise VerificationError, "missing_challenge" if challenge.blank?
stored_credential = verified_credential(challenge)
user = stored_credential.user
raise VerificationError, "deleted_user" if user.deleted?
stored_credential.save!
user.remember_me = true
sign_in(user)
reset_two_factor_auth_login_session
merge_guest_cart_with_user_cart
refresh_passkey_setup_prompt(user)
Rails.logger.info("passkey.authentication.succeeded user_id=#{user.id} webauthn_credential_id=#{stored_credential.id}")
render json: { success: true, redirect_location: login_path_for(user) }
rescue VerificationError => eView on GitHub (pinned to afeacbd394)
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.
Example fix
// before — submitting an assertion from a stale page (challenge consumed or expired)
await fetch("/logins/passkeys", { method: "POST", body: credentialJson }); // 422 missing_challenge
// after — fetch fresh options first, then submit once
const { options } = await (await fetch("/logins/passkeys/options")).json();
const credential = await navigator.credentials.get({ publicKey: publicKeyCredentialRequestOptionsFrom(options) });
await fetch("/logins/passkeys", { method: "POST", headers: { "Content-Type": "application/json" }, body: serialize(credential) }); Defensive patterns
Strategy: retry
Validate before calling
// client: run the full ceremony per attempt — options then create
const { options } = await fetchJson("/logins/passkeys/options");
if (!options.challenge) throw new Error("no challenge issued");
const credential = await navigator.credentials.get({ publicKey: toRequestOptions(options) });
await postAssertion(credential); Type guard
const hasChallengeStored = (options) => Boolean(options && options.challenge); // server set session[AUTHENTICATION_CHALLENGE_SESSION_KEY] when issuing these
Try / catch
// client: on 422, restart from the options call — the old challenge was consumed
try { await postAssertion(credential); }
catch (e) { const fresh = await getOptions(); await runCeremony(fresh); } Prevention
- 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.
When it happens
Trigger: 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.
Common situations: 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.
Related errors
- We couldn't sign you in with that passkey. Please try again
- deleted_user
- unknown_credential
- Could not add this passkey. Please try again.
- Sorry, something went wrong. Please try again.
AI-assisted analysis of antiwork/gumroad@afeacbd394 (2026-08-21).
Data as JSON: /api/errors/8c56b7b089b66aca.
Report an issue: GitHub.