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 => e

View on GitHub (pinned to afeacbd394)

Solutions

  1. Always run the ceremony in order: GET /logins/passkeys/options → navigator.credentials.get with the returned options → POST /logins/passkeys once.
  2. On failure, restart the flow from the options call — never re-post the old assertion; generate a fresh challenge for each attempt.
  3. Ensure cookies for the Gumroad domain are enabled (no ITP/iframe blocking) so the session carrying the challenge survives between the two requests.
  4. 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

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


AI-assisted analysis of antiwork/gumroad@afeacbd394 (2026-08-21). Data as JSON: /api/errors/8c56b7b089b66aca. Report an issue: GitHub.