Skipping payment, KYC, or email verification by calling final endpoints directly — multi-step processes where the backend trusts client-controlled state.
TL;DR
A workflow step bypass is a business logic vulnerability in which a multi-step process — combining authorization, payment, identity verification, or compliance review — is short-circuited by sending the final-step request directly without completing the prerequisites. It is catalogued as CWE-841 (Improper Enforcement of Behavioral Workflow) and is the focus of WSTG-BUSL-06 in the OWASP Web Security Testing Guide. It also appears in OWASP A04:2021 — Insecure Design (renamed A06:2025 — Insecure Design in the OWASP Top 10:2025 refresh) and as a dedicated Workflow Order Bypass entry in the OWASP Top 10 for Business Logic Abuse 2025.
The defining characteristic is a mismatch between the client-side experience and the server-side enforcement model. Developers build a clean linear UI — a checkout wizard with disabled "Next" buttons, a KYC page that hides "Approve" until documents are uploaded — and assume the user cannot reach step N without completing step N-1. The mistake is that the API endpoints behind those UI steps are independently reachable. An attacker using Burp Suite or curl can call POST /api/v1/checkout/complete directly and — if the server does not query the workflow state column in the database before processing — the order is created without payment.
The distinction between front-end enforcement and back-end enforcement is the entire vulnerability. Front-end logic guides users through the happy path; back-end logic is the only thing that protects business invariants. Every step with financial, security, or compliance significance must be modeled as a server-side state transition that later steps explicitly check. Anything less is decorative.
A multi-step workflow is fundamentally a finite state machine (FSM) over a transaction or session. The server should track which state each transaction is in (CART, ADDRESS, PAYMENT_PENDING, PAYMENT_CONFIRMED, COMPLETE) and reject any request that attempts a transition not allowed from the current state. When this check is missing or partial, any later endpoint becomes reachable from the initial state.
The skippable critical step varies by domain. The table below lists the common workflow shapes where this pattern recurs:
| Flow | Critical skippable step | Typical impact |
|---|---|---|
| E-commerce checkout | Payment collection | Free orders, financial fraud |
| KYC / identity verification | ID document upload + liveness check | AML compliance failure, money laundering risk |
| Account registration | Email verification | Account takeover via squatting, spam |
| Subscription activation | Payment method confirmation | Free access to paid tier indefinitely |
| Password reset | Old-password or token validation | Account takeover |
| Loan application | Credit check + income verification | Fraudulent loan approval |
A canonical attack request:
POST /api/v1/checkout/complete HTTP/1.1
Host: api.shop.example.com
Authorization: Bearer <user_jwt>
Content-Type: application/json
{"cart_id": "abc123", "shipping_address_id": "addr_456"}If the server returns {"order_id": "ORD-789", "status": "confirmed"} without a prior call to /api/v1/checkout/payment, payment was bypassed. The attacker never injected a payload — they simply called the last endpoint first.
| Variant | Technique | Impact |
|---|---|---|
| Direct URL navigation | Browse to /checkout/confirmation or /order/success?id=... directly in a fresh session | Order confirmed without payment page |
| Direct API call out of sequence | POST /api/v1/subscription/activate without prior /api/v1/payment/charge | Free subscription activation |
| Parameter forcing | Modify step=payment to step=complete or inject payment_status=paid in form body | Server trusts client-supplied state |
| Request replay in fresh session | Capture step-N request in Burp Repeater, replay with new cookies / no prior steps | Reveals stateless endpoints with no prerequisite check |
| Webhook spoofing | Forge POST /api/webhooks/stripe with fake success payload, no HMAC signature | Subscription marked paid without provider involvement |
| Hidden plan / feature flag cycling | Switch to internal dev plan via undocumented /api/plan/switch to reset trial (SaaS) | Indefinite free Enterprise-tier access |
| State machine sub-state race (CWOB) | HTTP/2 single-packet attack synchronizing two requests into ~1ms sub-state window (Kettle 2023) | Race past privilege downgrade or payment check |
The CWOB variant — Checkout Without Borders in the post-Kettle vocabulary — defeats the most common defensive pattern: a sequential check-then-act handler. James Kettle's August 2023 PortSwigger research, "Smashing the State Machine", showed that every HTTP request transitions an application through internal sub-states within ~1ms windows, and that the HTTP/2 single-packet attack — bundling 30 requests into the last frame of an HTTP/2 connection via Burp Turbo Intruder or h2spacex — synchronizes them into a sub-1ms execution window from a remote host. This turns remote race conditions into local ones, making sub-state exploitation viable against any internet-exposed FSM lacking atomic transitions.
The vulnerability class is consistently exploited at the highest severity tiers in CVE databases and bug bounty platforms.
CVE-2023-28121 — WooCommerce Payments (CVSS 9.8 CRITICAL, Actively Exploited)
Versions 4.8.0–5.6.1 of the WooCommerce Payments plugin failed to verify a user-supplied HTTP header (X-WCPay-Platform-Checkout-User); injecting it with an administrator's user ID let an unauthenticated attacker impersonate any user, bypassing the authentication step of every protected workflow (checkout, order management, plugin install). Mass exploitation began in July 2023. CVSS vector: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H.
CVE-2025-31161 — CrushFTP (CVSS 9.8 CRITICAL, Actively Exploited)
A race condition in the AWS4-HMAC authorization handler of CrushFTP 10.0.0–10.8.3 and 11.0.0–11.3.0. The server calls login_user_pass() with no password to verify user existence, authenticating the session via HMAC, then expects a second privilege check. The ~5ms window between sub-states lets an attacker issue authenticated admin requests before the privilege downgrade executes — a textbook CWOB. First exploited in the wild March 31, 2025 (Shadowserver). Fix in 10.8.4 / 11.3.1 made the verify-then-downgrade transition atomic.
CVE-2025-14971 — Link Invoice Payment for WooCommerce (CVSS 5.3, Jan 30 2026). The createPartialPayment and cancelPartialPayment REST API functions in versions ≤ 2.8.0 perform no capability checks; an unauthenticated attacker can create fraudulent partial payments or cancel legitimate ones on any order by enumerating IDs (CWE-862).
HackerOne #682617 — Starbucks Switzerland (Disclosed). Researcher crafted a spoofed payment success message to the card.starbucks.ch payment callback endpoint, crediting a Swiss Starbucks Card without a real transaction — payment callback forgery in production.
HackerOne #423546 — Shopify Wholesale (H1-514 Live Hacking Event). The Wholesale Channel lets shops disable checkout so purchase orders require staff review. The researcher tricked the checkout router into completing a regular (unreviewed) checkout even with checkout explicitly disabled, bypassing the mandatory staff-review step.
HackerOne #953083 — Shopify Theme Store ($2,000 Bounty). Attacker published a paid third-party theme on their store without purchasing it by sending a ThemePublishLegacy XHR while a theme install was in progress. The install workflow (add-to-cart → payment → license → install → publish) was bypassed by jumping to publish during the install window — a real-world sub-state race.
HackerOne #1328278 — Stripe (Disclosed). Users could purchase a product at an archived (deactivated) price using a previously-generated payment link. Stripe's link processing failed to validate that both the link and the price were active at checkout time.
HackerOne #1420697 — lemlist (Disclosed). Improper payment-method handling at app.lemlist.com let an attacker activate a paid plan without completing a transaction. The activation endpoint never verified the payment provider's confirmation.
HackerOne #2012443 — Nord Security / NordVPN (Disclosed). A flaw in the backend service verifying active subscription status could be bypassed, granting VPN access to users without a valid subscription (session-scoped).
HackerOne #2170559 — Cloudflare R2 (Sept 18 2023, Disclosed). Insufficient access control checks let a user enable R2 object storage with no payment method on file — the prerequisite "valid payment method" check in the activation workflow was not enforced server-side.
The Burp Repeater methodology follows a five-step CWOB testing protocol drawn from PortSwigger's Insufficient Workflow Validation lab and the post-CWOB research:
/checkout/complete, /subscription/activate, /kyc/approve).step, stage, status, payment_status, verified, confirmed in request bodies. Force them to a later state.h2spacex Scapy-based library performs HTTP/2 last-frame synchronization for precise race-condition timing.For webhook endpoints, search JS bundles and OpenAPI docs for paths like /webhooks/stripe. Send a crafted JSON body mimicking the provider's success format (e.g., {"type": "payment_intent.succeeded", "data": {"object": {"id": "pi_xxx"}}}) without a signature. If the app marks payment complete, HMAC verification is absent.
Traditional DAST scanners (Burp Active Scan, OWASP ZAP) cannot detect workflow bypass by design — they test individual requests without understanding business state machines. Purpose-built tools have emerged in 2025–2026 to address this gap:
BreachVex detects this via sequence-aware probing of multi-step endpoints combined with webhook signature validation. The scanner maps discovered API endpoints into workflow clusters (checkout, subscription, KYC, password reset, registration), then for each cluster sends the inferred final-step request in a fresh authenticated session without prior steps. Findings are flagged POTENTIAL_WORKFLOW_BYPASS when the final endpoint returns HTTP 200 with order/confirmation/activation keywords; they are promoted to CONFIRMED only after a state-comparison check observes a server-side state change. Webhook endpoints are probed with an unsigned forged success payload; if the response indicates state mutation, a separate finding is raised with a CWE-345 / CWE-841 chain.
The only durable defense is to model each multi-step workflow as a finite state machine on the server, with state stored in the database (or a server-side session store) and never derived from client-supplied parameters. Each transition must validate the current state before executing.
# VULNERABLE — no state check, server trusts that the front-end called payment first
@require_POST
def complete_order_vulnerable(request):
checkout_id = request.POST["checkout_id"]
checkout = Checkout.objects.get(id=checkout_id)
order = create_order(checkout) # No payment check, no state guard
return JsonResponse({"order_id": order.id, "status": "confirmed"})
# FIXED — explicit FSM with current_step column in DB and transition table
class CheckoutState(models.TextChoices):
CART = "cart"
ADDRESS = "address"
PAYMENT_PENDING = "payment_pending"
PAYMENT_CONFIRMED = "payment_confirmed"
COMPLETE = "complete"
CANCELLED = "cancelled"
class Checkout(models.Model):
id = models.UUIDField(primary_key=True)
user = models.ForeignKey(User, on_delete=models.CASCADE)
current_step = models.CharField(
max_length=32,
choices=CheckoutState.choices,
default=CheckoutState.CART,
)
payment_intent_id = models.CharField(max_length=64, null=True)
payment_confirmed_at = models.DateTimeField(null=True)
ALLOWED_TRANSITIONS = {
CheckoutState.CART: {CheckoutState.ADDRESS, CheckoutState.CANCELLED},
CheckoutState.ADDRESS: {CheckoutState.PAYMENT_PENDING, CheckoutState.CANCELLED},
CheckoutState.PAYMENT_PENDING: {CheckoutState.PAYMENT_CONFIRMED, CheckoutState.CANCELLED},
CheckoutState.PAYMENT_CONFIRMED: {CheckoutState.COMPLETE, CheckoutState.CANCELLED},
}
@require_POST
def complete_order(request):
checkout = get_object_or_404(
Checkout, id=request.POST["checkout_id"], user=request.user
)
if checkout.current_step != CheckoutState.PAYMENT_CONFIRMED:
return JsonResponse(
{
"error": "WORKFLOW_STATE_INVALID",
"current_state": checkout.current_step,
"required_state": CheckoutState.PAYMENT_CONFIRMED,
},
status=400,
)
order = create_order(checkout)
checkout.current_step = CheckoutState.COMPLETE
checkout.save()
return JsonResponse({"order_id": order.id, "status": "confirmed"})Never let the client signal payment success. Use server-to-server webhooks with cryptographic signature verification on every event before any state mutation.
# Stripe webhook signature verification (Python) — REQUIRED for every payment webhook
import os
import stripe
from flask import request
stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
WEBHOOK_SECRET = os.environ["STRIPE_WEBHOOK_SECRET"] # whsec_...
@app.route("/webhooks/stripe", methods=["POST"])
def stripe_webhook():
payload = request.get_data() # raw bytes — required for HMAC
sig_header = request.headers.get("Stripe-Signature")
try:
event = stripe.Webhook.construct_event(
payload, sig_header, WEBHOOK_SECRET
)
except (ValueError, stripe.error.SignatureVerificationError):
# Reject unsigned or forged events — this is the bypass-prevention line
return "", 400
# Only after signature verification, mutate state
if event["type"] == "payment_intent.succeeded":
intent = event["data"]["object"]
checkout = Checkout.objects.get(payment_intent_id=intent["id"])
checkout.current_step = CheckoutState.PAYMENT_CONFIRMED
checkout.payment_confirmed_at = timezone.now()
checkout.save()
return "", 200The Node.js equivalent uses stripe.webhooks.constructEvent(rawBody, signature, secret) with the raw request body. Parsing the JSON first and re-stringifying it breaks the HMAC because canonical byte representation is lost — a common silent bug.
To defeat the post-2023 CWOB single-packet attack, every state transition must be wrapped in a database transaction with a row-level lock acquired via SELECT FOR UPDATE. This serializes concurrent attempts to read-then-write the same workflow row, eliminating the ~1–5ms sub-state window that single-packet attacks target.
from django.db import transaction
def complete_order_atomic(checkout_id, user):
with transaction.atomic():
checkout = (
Checkout.objects
.select_for_update() # row-level lock — serializes concurrent requests
.get(id=checkout_id, user=user)
)
if checkout.current_step != CheckoutState.PAYMENT_CONFIRMED:
raise WorkflowStateError(
current=checkout.current_step,
required=CheckoutState.PAYMENT_CONFIRMED,
)
checkout.current_step = CheckoutState.COMPLETE
checkout.save()
order = create_order(checkout)
return orderThe SELECT FOR UPDATE is the single most important line for race defense — without it, two concurrent requests can both observe PAYMENT_CONFIRMED, both flip state to COMPLETE, and both create orders. With it, the second request blocks until the first commits, re-reads the new state, and is rejected. Combined with FSM enforcement and HMAC-verified webhooks, this closes the workflow bypass class against both the classic skip attack and the HTTP/2 single-packet CWOB.
Workflow step bypass is a business logic flaw where a multi-step process — checkout, KYC, email verification, subscription activation — is short-circuited by calling the final-step endpoint directly without completing prerequisites. Mapped to CWE-841 (Improper Enforcement of Behavioral Workflow), surfaced by OWASP WSTG-BUSL-06, and listed under OWASP A04:2021 (renamed A06:2025 — Insecure Design). The new OWASP Top 10 for Business Logic Abuse 2025 names Workflow Order Bypass as a dedicated category. Real cases include CVE-2023-28121 and HackerOne #2170559 (Cloudflare R2).
By identifying the final endpoint (POST /api/v1/checkout/complete or /orders/finalize), capturing it in Burp Repeater during a normal flow, then replaying it in a fresh session without ever calling the payment endpoint. If the server returns an order_id and confirmed status, payment was bypassed. Real cases: HackerOne #682617 (Starbucks Switzerland payment callback forgery), HackerOne #1420697 (lemlist direct activation), HackerOne #2170559 (Cloudflare R2 paid storage without payment method, September 2023).
CWOB is the post-Kettle vocabulary for sub-state race conditions in workflow handlers, popularized after PortSwigger Research's August 2023 paper 'Smashing the State Machine'. The exploit uses HTTP/2 single-packet attack to synchronize 30 requests into a sub-1ms window, exploiting the gap between authentication sub-states or pre-payment validation. CVE-2025-31161 (CrushFTP, CVSS 9.8 Critical, actively exploited March 2025) is the canonical CWOB case: AWS4-HMAC handler had a ~5ms window between auth sub-states allowing CWOB bypass.
CVE-2023-28121 affects WooCommerce Payments 4.8.0–5.6.1 (CVSS 9.8 Critical, vector CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H). The bug is an unauthenticated authentication bypass via the X-WCPay-Platform-Checkout-User HTTP header — supplying a target user_id in this header impersonates that user without any password or token. Attackers used it to take over admin accounts in tens of thousands of WordPress sites. Actively exploited within days of disclosure; patched in 5.6.2 and propagated via WordPress auto-updates.
Webhook spoofing forges a POST /api/webhooks/stripe (or equivalent provider callback) with a fake success payload, marking subscription/payment complete without provider involvement. Prevention: verify HMAC signature on every webhook using stripe.Webhook.construct_event(payload, sig_header, secret) in Python, or the equivalent Stripe SDK call in any language. The signature must be computed over the raw request body before any JSON parsing. HackerOne #682617 (Starbucks) and Jack Cable's 2018 disclosure are canonical examples; the pattern still appears in 2024–2026 audits.
Never. Any state field supplied by the client (step=complete, payment_status=paid, kyc_verified=true, plan=enterprise) is attacker-controlled. The server must derive workflow state by querying the canonical record (orders.payment_status, kyc_records.status) inside the same transaction that performs the next state transition. The only safe assumption is that every client field is hostile, even if the official UI is well-behaved. Server-side state machine enforcement with explicit transition tables is the structural fix.
Define an explicit FSM with allowed transitions: CART → ADDRESS → PAYMENT_PENDING → PAYMENT_CONFIRMED → COMPLETE. Store current_step in the workflow row. On every state-changing request: BEGIN TRANSACTION; SELECT current_step FROM workflows WHERE id=? FOR UPDATE; verify the requested transition is in the allowed-set table; UPDATE current_step; COMMIT. The SELECT FOR UPDATE prevents CWOB races. Reject any request that does not match the allowed transition with a 409 Conflict. CVE-2025-31161 happened because this transactional pattern was missing.