Private betaZynth Auth is currently in private beta testing.New organizations are created by invitation only, and no plan can be purchased yet.Request early access

Payment security

Zynth Auth authenticates the human. Your payment processor moves the money. No card data ever reaches this platform — that boundary is what keeps you and us in PCI DSS SAQ-A, and nothing in this guide crosses it.

Three capabilities, each usable on its own:

What it answers
Step-up"Prove it's still you, right now, before I do this sensitive thing."
SCA + dynamic linking"Confirm this exact payment — and refuse if any detail changed."
Risk context"What does the platform know about this customer before I decide to challenge?"

Availability. The surface ships off. Until an operator sets PAYMENTS_STEPUP_ENABLED, every /api/v1/payments/* route returns 404 on every HTTP method — a wrong-method probe cannot tell the routes apart from ones that were never built (ADR-0084). See Self-hosted deployments.

Before you start

Your backend needs the payments:verify permission to consume an elevation. Issue it an API key with that scope, or assign a role that grants it. Your users need no permission at all to prove themselves — being themselves is the point.

Your users need a step-up-capable factor. TOTP or a passkey. A password-only user cannot step up, and the API says so rather than pretending: no_factor.

Step-up: a fresh proof for one exact operation

Ask for an elevation, naming the operation and any context that must not change:

curl -X POST -H "Authorization: Bearer $USER_TOKEN" -H "Content-Type: application/json" \
  -d '{"action": "add_payment_method", "context": {"card_last4": "4242"}}' \
  https://auth.example.com/api/v1/payments/step-up

You get back the elevation and the factors this user can actually use:

{ "elevation": { "id": "c02106df-…", "status": "pending" }, "methods": ["totp"] }

The user satisfies it with a fresh factor:

curl -X POST -H "Authorization: Bearer $USER_TOKEN" -H "Content-Type: application/json" \
  -d '{"method": "totp", "code": "123456"}' \
  https://auth.example.com/api/v1/payments/step-up/$ELEVATION_ID/verify

Then your backend spends it, immediately before performing the operation:

curl -X POST -H "Authorization: Bearer $SERVICE_TOKEN" -H "Content-Type: application/json" \
  -d '{"elevation_id": "c02106df-…", "user_id": "df25bc04-…", "sid": "ae131b32…",
       "action": "add_payment_method", "context": {"card_last4": "4242"}}' \
  https://auth.example.com/api/v1/payments/elevations/consume

A refusal means do not proceed. Each proof works exactly once, expires quickly, and dies with the session that earned it — if the user signs out, changes their password, is deprovisioned by your IdP, or has their sessions revoked by a security response, the elevation stops working immediately. That is the property a self-contained token could not have.

SCA with dynamic linking

For a payment, use the SCA challenge instead. It binds the confirmation to the transaction.

1. Raise the challenge

curl -X POST -H "Authorization: Bearer $USER_TOKEN" -H "Content-Type: application/json" \
  -d '{"amount_minor": 14999, "currency": "PHP",
       "payee": "acme-store", "payee_name": "Acme Store"}' \
  https://auth.example.com/api/v1/payments/sca/challenge
{
  "elevation": { "id": "c02106df-8063-412f-868c-7ab870a94cdb", "status": "pending" },
  "methods": ["totp"],
  "display": "Confirm PHP 149.99 to Acme Store"
}

Show display to the payer verbatim. It is rendered by the server from the exact bytes it bound. If your client composes its own sentence from its own copy of the amount, you have reintroduced the gap this design exists to close — the one where the customer approves one figure and a different one is charged.

Two rules the request enforces, both fail-closed:

  • amount_minor is an integer in minor units14999, never 149.99. Decimals are refused, because a float is not a reliable way to carry money.
  • currency must be a supported ISO-4217 code. An unknown code is a 400 that lists the supported set, rather than a guessed decimal position. JPY 14999 is ¥14,999, not ¥149.99 — guessing that exponent would show your customer a figure 100× wrong.

2. The customer confirms

Same verify call as step-up. Strong customer authentication needs two independent factor types, and Zynth works them out from how the customer signed in plus this fresh challenge — so nobody is asked for a redundant code:

Signed in withFresh factorResult
PasswordTOTP✅ knowledge + possession
PasskeyTOTP✅ already two from the session
AnythingPasskey✅ possession + inherence in one touch
Sign-in linkTOTPinsufficient_factors

Sign-in links deliberately count for nothing here — email is the classic account-takeover route. Federated sign-in (social/SSO) doesn't count either: the strength belongs to your identity provider and isn't ours to assert. Both cases are still fine — the customer just earns both factors from the fresh challenge, and a passkey does that in one step.

A 403 insufficient_factors names what is missing and suggests the passkey route.

3. Your backend consumes it — and this is where the binding pays off

curl -X POST -H "Authorization: Bearer $SERVICE_TOKEN" -H "Content-Type: application/json" \
  -d '{"elevation_id": "…", "user_id": "…", "sid": "…", "action": "sca.payment",
       "context": {"amount_minor": 14999, "currency": "PHP",
                   "payee": "acme-store", "payee_name": "Acme Store"}}' \
  https://auth.example.com/api/v1/payments/elevations/consume

Re-send the transaction exactly as bound. On success you get an attestation — the evidence a chargeback or regulatory review asks for:

{
  "valid": true,
  "action": "sca.payment",
  "approved_at": "2026-08-09T13:40:59.767329+00:00",
  "consumed_at": "2026-08-09T13:40:59.786592+00:00",
  "attestation": {
    "transaction": { "amount_minor": 14999, "currency": "PHP",
                     "payee": "acme-store", "payee_name": "Acme Store" },
    "display": "Confirm PHP 149.99 to Acme Store",
    "factors": ["knowledge", "possession"],
    "session_amr": ["pwd"],
    "fresh_amr": "otp"
  }
}

It records what was approved, what the payer was shown, and which two factor types proved it — not merely that something was valid.

Change any bound detail and the confirmation is refused:

{ "detail": { "code": "fingerprint_mismatch",
              "message": "the operation does not match the one this elevation was granted for" } }

That covers the amount, the currency, the payee identifier — and the payee's display name, because a swapped name on the same account is still a swapped payee. Spending a confirmation twice is refused the same way (not_spendable).

Risk context — inputs, never a verdict

Before deciding whether to challenge at all, ask what the platform knows:

curl -H "Authorization: Bearer $SERVICE_TOKEN" \
  "https://auth.example.com/api/v1/payments/risk/context?user_id=$USER_ID&sid=$SID"
{
  "score": 32,
  "band": "low",
  "degraded": false,
  "factors": [
    { "name": "account_age", "risk": 1.0, "weight": 0.2, "detail": "account is 0d old (new)" },
    { "name": "session_age", "risk": 0.0, "weight": 0.2, "detail": "authenticated within the hour" },
    { "name": "factor_strength", "risk": 0.5, "weight": 0.25,
      "detail": "session proves only ['knowledge']" },
    { "name": "open_detections_user", "risk": 0.0, "weight": 0.25, "detail": "no open detections for this user" }
  ],
  "unavailable": [ { "factor": "device_familiarity", "reason": "…" }, { "factor": "geo_velocity", "reason": "…" } ],
  "note": "Risk inputs only — this is not an SCA exemption decision. …"
}

Read the factors, not just the band. That example is real, and it makes the point: the account is brand new — the single highest-risk signal present, at 1.0 — yet the overall band is low, because everything else about the session is clean. A blended score is a summary, and summaries lose exactly the detail a fraud rule cares about. If account age matters to you, read account_age; don't infer it from score.

open_detections_user is where account-takeover signals reach checkout. Zynth's detection engine watches for the ATO pattern directly: three security settings changed by one actor inside ten minutes — MFA disabled, recovery codes regenerated, password changed — is a takeover signature, even though each of those actions alone is ordinary self-service and none of them alone raises anything. A second rule catches the spread across an organization: five users losing MFA in an hour. While such a detection is open, this factor scores against that user, so the risk band your checkout reads moves on its own — no integration work, no webhook.

Deliberately, an ATO detection does not make the platform revoke anything by itself. It raises the risk, and your gate decides. That keeps a false positive expensive in challenges rather than in locked-out customers.

Two more things this response tells you honestly:

  • unavailable lists factors the platform does not yet compute (device familiarity, geo-velocity). They are disclosed rather than silently omitted, so you never read a silence as "clean".
  • degraded: true means a factor we do compute could not be read and was scored at worst case. The score is then a floor — treat it as a reason to challenge, never as an all-clear.

This is not an exemption decision. Zynth never rules that a payment is exempt from strong authentication. We describe; your app or fraud tool decides. That boundary is deliberate: asserting exemption eligibility means owning a fraud-rate calculation with regulatory consequences.

Self-hosted deployments

VariableDefaultWhat it does
PAYMENTS_STEPUP_ENABLEDfalseThe gate. Off ⇒ the whole /api/v1/payments/* surface returns 404.

Per-organization bounds — how long an approved elevation stays spendable, and how many failed factor attempts one challenge tolerates — are operator-only settings, not environment variables. Ask your operator to change them; the defaults are 5 minutes and 5 attempts.

Next