{"record":{"id":"23b339fef570092e","repo":"we-promise/sure","slug":"token-compromised","errorCode":"token_compromised","errorMessage":"Setup token may be compromised, expired, or already used","messagePattern":"Setup token may be compromised, expired, or already used","errorType":"exception","errorClass":"Provider::Simplefin::SimplefinError","httpStatus":403,"severity":"critical","filePath":"app/models/provider/simplefin.rb","lineNumber":48,"sourceCode":"  end\n\n  def claim_access_url(setup_token)\n    # Decode the base64 setup token to get the claim URL\n    claim_url = Base64.decode64(setup_token)\n\n    # Use retry logic for transient network failures during token claim\n    # Claim should be fast; keep request-path latency bounded.\n    # Use self.class.post to inherit class-level SSL and timeout defaults\n    response = with_retries(\"POST /claim\", max_retries: 1, backoff: false) do\n      self.class.post(claim_url, timeout: 15)\n    end\n\n    case response.code\n    when 200\n      # The response body contains the access URL with embedded credentials\n      response.body.strip\n    when 403\n      raise SimplefinError.new(\"Setup token may be compromised, expired, or already used\", :token_compromised)\n    else\n      raise SimplefinError.new(\"Failed to claim access URL: #{response.code} #{response.message}\", :claim_failed)\n    end\n  end\n\n  def get_accounts(access_url, start_date: nil, end_date: nil, pending: nil)\n    # Build query parameters\n    query_params = {}\n\n    # SimpleFin expects Unix timestamps for dates\n    if start_date\n      start_timestamp = start_date.to_time.to_i\n      query_params[\"start-date\"] = start_timestamp.to_s\n    end\n\n    if end_date\n      end_timestamp = end_date.to_time.to_i\n      query_params[\"end-date\"] = end_timestamp.to_s","sourceCodeStart":30,"sourceCodeEnd":66,"githubUrl":"https://github.com/we-promise/sure/blob/e69894adb92547273377398c15f45c979cd9416a/app/models/provider/simplefin.rb#L30-L66","documentation":"Raised by Provider::Simplefin#claim_access_url when POSTing the decoded claim URL returns 403, with error_type :token_compromised. SimpleFin setup tokens are one-time, short-lived claim tokens: a 403 means the token was already claimed, has expired, or was flagged compromised. Each setup token can produce exactly one access URL; claiming is not repeatable.","triggerScenarios":"Calling claim_access_url twice with the same base64 setup token (double-clicked submit, retried job, duplicate webhook); a stale token past its validity window; the user re-issuing the token invalidating the older copy.","commonSituations":"Idempotency bug where a background job retries the claim after a partially failed first attempt; user pastes an old token from a previous attempt; clock gaps between token generation and claim.","solutions":["Generate a fresh setup token from SimpleFin and claim that one exactly once","Make claim idempotent in your flow: persist the claimed access URL keyed by token so retries reuse it instead of re-claiming","Never auto-retry a 403 claim — request a new token from the user instead","If the user is certain the token is fresh, have them re-generate; SimpleFin support can confirm compromise flags"],"exampleFix":"# before\naccess_url = client.claim_access_url(setup_token) # retried job claims twice -> 403\n\n# after\naccess_url = ClaimedToken.where(setup_token_digest: Digest::SHA256.hexdigest(setup_token)).pick(:access_url)\naccess_url ||= ClaimedToken.create!(setup_token_digest: Digest::SHA256.hexdigest(setup_token), access_url: client.claim_access_url(setup_token)).access_url","handlingStrategy":"validation","validationCode":"def plausible_setup_token?(setup_token)\n  decoded = Base64.decode64(setup_token.to_s)\n  decoded.start_with?(\"https://\") && !decoded.include?(\" \")\nrescue ArgumentError\n  false\nend\n\nraise ArgumentError, \"setup token does not decode to a claim URL\" unless plausible_setup_token?(setup_token)","typeGuard":"def simplefin_token_compromised?(error)\n  error.is_a?(Provider::Simplefin::SimplefinError) && error.error_type == :token_compromised\nend","tryCatchPattern":"begin\n  access_url = client.claim_access_url(setup_token)\nrescue Provider::Simplefin::SimplefinError => e\n  raise unless e.error_type == :token_compromised\n  clear_pending_claim!(user) # one-time token is burned; require a fresh one\nend","preventionTips":["Persist claimed access URLs keyed by token digest so job retries never re-claim","Make the claim step exactly-once in the UI: disable the submit button after first click","Never auto-retry 403 claims — always ask the user for a new setup token"],"tags":["simplefin","setup-token","claim","one-time-token","http-403"],"backgroundTag":"one-time-token-already-used","analyzedSha":"e69894adb92547273377398c15f45c979cd9416a","analyzedAt":"2026-08-21T18:22:41.165Z","schemaVersion":2},"datasetVersion":"2026-08-21T23:17:16.201Z"}