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 returns404on 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_minoris an integer in minor units —14999, never149.99. Decimals are refused, because a float is not a reliable way to carry money.currencymust be a supported ISO-4217 code. An unknown code is a400that lists the supported set, rather than a guessed decimal position.JPY 14999is ¥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 with | Fresh factor | Result |
|---|---|---|
| Password | TOTP | ✅ knowledge + possession |
| Passkey | TOTP | ✅ already two from the session |
| Anything | Passkey | ✅ possession + inherence in one touch |
| Sign-in link | TOTP | ❌ insufficient_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:
unavailablelists 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: truemeans 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
| Variable | Default | What it does |
|---|---|---|
PAYMENTS_STEPUP_ENABLED | false | The 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
- Tokens & sessions — everything that ends a session, and therefore everything that invalidates an elevation.
- Authentication methods — enrolling TOTP and passkeys, which users need before they can step up.
- API reference — the endpoint contracts.