Fintech

How to Bind a Payment Approval to Exact Parameters

Antonio Evans9 min read

A split scene: on the left, an approval card showing amount, payee, and invoice number with a green checkmark; on the right, the same card with the payee field changed to a different name and a red X.
Key points

Key points

  1. 1An approval that names only an invoice number or a vendor is not a control, because the amount and the payee account can drift between the click and the transfer.
  2. 2Sign a canonical payload of amount, currency, payee account identifier, invoice number, expiry and a unique nonce with a key the approver never holds.
  3. 3Verify the signature against the exact bytes you are about to send to the payment rail, not against a re-read of your database.
  4. 4Server-side state with idempotency keys is easier to audit but slower to scale, so most teams pair signed payloads with a one-time consumption record.

Why an approval screen is not an approval

The gap between what a human approved and what the system executes is where payment fraud lives. A reviewer sees a card that says "Invoice 4471, Acme Supply, $18,400" and clicks approve. Ten minutes later an agent or a background job reads the vendor master again and sends $18,400 to a different account, because someone changed the beneficiary record in between. The human approved a screen, not a transfer.

This is not a hypothetical. Payee substitution through business email compromise and manipulated beneficiary data is a recognized payment fraud path, and the standard layered-control answer includes dual authorization, out-of-band verification and payee controls rather than trust in a single authentication step per the FFIEC supplement on high-risk transactions. Agents make it worse: an injected instruction inside an invoice PDF or a supplier email can steer one payment to the wrong destination without extracting any data at all, which OWASP describes as indirect prompt injection.

A confirmation dialog adds a second factor only if the approver cannot forge the approval and the approved details cannot drift before execution. Otherwise it is theater. The rule that follows is simple: authorization must be a signed statement about one exact effect, and execution must refuse any effect that does not hash to the same statement.

Payouts draws the same boundary in its guidance on AI agent payment controls: "Approved invoice" is not a sufficient execution instruction, and a material change to bank details, amount, currency or payer entity should invalidate the earlier authorization rather than carry it forward.

The canonical payload

Start with the fields that change what money moves. Anything that alters the destination, the amount, the funding source or the timing belongs inside the signature. Anything that only helps humans find the record can stay outside.

FieldWhy it is signedExample
payer_entityDifferent legal entities have different funding accounts and policiesUS-OPCO-1
invoice_refTies the payment to a liability someone can auditINV-4471
amount_minorInteger minor units avoids float rounding drift1840000
currencyPrevents a USD approval being reused for EURUSD
payee_idInternal vendor identity, stable across bank changesVND-0093
payee_accountThe actual destination that fraud swapsiban:DE89...
payee_account_versionBeneficiary record version at approval time7
methodACH, SEPA, wire, stablecoin rail: the rail changes the risksepa_credit
not_before, expShort window limits replay valueepoch seconds
nonceMakes every approval unique even for identical payments128-bit random
approver_sub, policy_versionProves who acted and under which rule setu_8812, pol-12

Two details matter more than the list. First, serialize deterministically: sorted keys, no whitespace, integers as integers, and a documented string encoding for the account identifier. If you sign JSON with one library and verify with a second, key ordering and number formats will differ and valid approvals will fail, or worse, a lenient parse will let an attacker add fields. Second, version the schema. Include schema: "payappr.v1" inside the signed bytes so a future field addition cannot be silently accepted by an old verifier.

A compact payload looks like this:

{
  "schema": "payappr.v1",
  "iss": "approvals.internal",
  "payer_entity": "US-OPCO-1",
  "invoice_ref": "INV-4471",
  "amount_minor": 1840000,
  "currency": "USD",
  "payee_id": "VND-0093",
  "payee_account": "iban:DE89370400440532013000",
  "payee_account_version": 7,
  "method": "sepa_credit",
  "iat": 1767225600,
  "exp": 1767226500,
  "nonce": "9f2c1e7d4b6a48c1a0f3e5d2b7c9a1e4",
  "approver_sub": "u_8812",
  "policy_version": "pol-12"
}

A fifteen-minute window is a reasonable default. It is long enough for a human to approve and a job to pick up, and short enough that a stolen token has little resale value. The principle that unanswered approvals should expire and count as a rejection comes from Kissflow's write-up on approval workflow agents, and the same logic applies to signed tokens: expiry is a control, not a courtesy.

A replay with a changed payee, step by step

Here is the attack the design has to stop. It takes four moves and no privileged access beyond the ability to write to one database column or to influence one agent.

  1. Monday 09:14. An AP clerk prepares INV-4471 for Acme Supply, $18,400, SEPA credit to the account recorded at beneficiary version 7. The system mints an approval token over the payload above and shows the reviewer a card rendered from the same fields.
  2. Monday 09:19. The reviewer approves. In a naive build, the endpoint flips invoice.status = 'approved' and stores nothing else.
  3. Monday 09:31. A compromised mailbox, a malicious invoice attachment or an over-broad agent updates the vendor master: account version 8 now points at a different IBAN. The invoice row still says approved.
  4. Monday 09:40. The payment job runs, sees an approved invoice, reads the current beneficiary and sends $18,400 to the new account.

With a signed token, step 4 fails. The job rebuilds the canonical payload from the beneficiary at version 8, hashes and verifies it against the token, and the payee_account, payee_account_version and therefore the signature no longer match. The release service rejects with a specific reason code, opens a re-approval task, and the money stays put.

The verification must be over the bytes you will actually send. A common bug is to verify the token against a freshly loaded invoice, then hand the rail adapter a separately constructed request built from a cached object. Build the outgoing request from the same canonical serializer, and pass the serialized string to both the verifier and the adapter so they cannot diverge.

One more failure mode deserves a name: replay of the same valid token. If an attacker captures the approved token and submits it twice, both submissions verify. The nonce plus a consumption record closes this. Mark the nonce used inside the same transaction that creates the payment instruction, and reject a second use. Your payment rail may also enforce this: idempotency keys on the provider side reject duplicate submissions for a window the provider defines, which is worth reading in the rail's own documentation before you rely on it.

Three implementations compared

There is no single right answer. The choice depends on how many services must verify, how long approvals live, and whether you need to prove consumption.

ApproachWhat the approver holdsWhere validity livesStrengthsWeaknessesGood fit
HMAC-signed payloadOpaque base64 payload plus an HMAC tagIn the payload plus a nonce tableTiny dependency footprint, fast, easy to reason about, deterministicCustom format, no standard tooling, both signer and verifier need the same symmetric keySingle service, short expiry, internal tools
Signed JSON (JWS)A JWS compact string with the payload as claimsIn the token plus a nonce tableStandard parsing and verification libraries, asymmetric keys let verifiers hold only a public key, supports kid rotationLibrary pitfalls: algorithm confusion, duplicate JSON keys, claim validation is easy to skipMultiple verifiers, external rails, audit-heavy environments
Server-side state with idempotency keysAn opaque approval IDEntirely in your databaseSimplest mental model, trivially revocable, clear audit trail, no crypto bugsEvery verifier must call you, database is the trust boundary, state must be transactionalFew consumers, long approval windows, human review queues

An HMAC over the canonical string is roughly ten lines in any language and leaves little room for subtle error, provided the key lives in a secret manager and never reaches the client. The trade-off is that every verifier needs the signing key, so a compromise anywhere becomes a compromise everywhere.

JWS solves the key distribution problem by letting verifiers hold only the public half. The discussion of HMAC versus JWT on Stack Overflow is worth reading for the framing: JWT is a standard where the HMAC scheme is custom, which buys you libraries and reviewers, and costs you configuration surface. If you take this route, pin the accepted algorithms, reject tokens with duplicated claim names, and validate every claim yourself instead of trusting the library's defaults.

Server-side state is underrated. An approval row with status, expires_at, consumed_at and a foreign key to the exact beneficiary version is easy to audit and easy to revoke. The cost is a synchronous lookup on the release path and a database that must be transactional with the payment creation. For approval queues where humans sit on requests for hours or days, the state model handles revocation and re-approval far more cleanly than any token.

The practical pattern most teams converge on is hybrid: sign the payload, keep a nonce and approval record server-side, and verify both. The token gives you tamper evidence without a lookup; the record gives you consumption, revocation and an audit trail.

Presenting the approval, not just signing it

A cryptographically correct token over a misleading summary is still a hole. If the approval card says "Acme Supply" while the signed payee_account points somewhere else, the reviewer approved the label, not the payment. Render the approval card from the exact canonical payload and show the fields that matter, including the account identifier in a form a human can check against a known statement.

Where payee verification is available, use it. The European Verification of Payee scheme requires payment service providers to check that the payee name and IBAN match and return match, close match, no match or verification not possible, with responses expected within five seconds per TIS on the VoP mandate. Bake the result into the payload as a vop_result and vop_at field, and refuse to issue a token when the result is "no match" unless a named person explicitly overrides with a documented reason. Commercial tools do the same job across more jurisdictions: iPiD's payment agent captures intent, runs account validation and flags mismatches before payment creation. Whatever source you use, the verification timestamp belongs inside the signature, because "verified yesterday" is not "verified now."

For agent-initiated payments, keep the authority outside the model. The model may propose a payload; a policy service decides whether the payload is permitted; a human approves the signed payload; a separate release service verifies and sends. RedactSure's analysis of fraud paths in agent payments makes the point that the named person at the point of commitment is the control, and OWASP's mitigation list puts human approval for high-risk actions and least-privilege tool access alongside input filtering, because prompt filtering alone does not stop injection.

Spending limits are a separate control that complements the token. A $5 auto-approve threshold with human review above it limits how much the token mechanism has to carry, and the same page notes that per-transaction caps and rate limits stop a retry loop from draining a balance. FluxA's comparison of budgeting methods makes a similar argument: API key alerting is retrospective, so the hard limits need to sit in the layer that authorizes the spend. Set your approval threshold and your token expiry so that the largest unapproved amount is small enough to survive a mistake.

A verification checklist before you ship

Walk through these in order on the release path. Each one is a one-line test you can write.

  • The schema field equals a value your verifier knows, and unknown fields cause rejection rather than inclusion.
  • The canonical serialization is deterministic and shared by signer, verifier and rail adapter.
  • exp is in the future, iat is not in the future by more than your clock skew allowance, and the window is short.
  • payer_entity, amount_minor, currency, payee_account and payee_account_version match the instruction about to be sent, byte for byte.
  • The nonce has not been consumed, and consumption happens in the same transaction that creates the payment.
  • The signing key is in a secrets manager, rotated on a schedule, and the verifier accepts only the algorithms you intend.
  • Every rejection returns a reason code, and every approval and rejection lands in an immutable log with the executed parameters.
  • The approval card is rendered from the signed payload, not from a second read of the database.

That last item is the one teams skip and then regret. Logging belongs in the design from the start too: Stampli's description of payment approval workflows treats the approval as a distinct control point from invoice approval, focused on cash release, which is exactly the separation the token enforces. If you need an audit story you can reconstruct a decision from, record the approver, the policy version, the nonce, the canonical payload and the parameters that actually executed.

Two operational notes. First, rotation: keep a kid in the header or the payload, keep the previous key valid for one expiry window, and verify against the key the token names. Second, clock skew: allow a small tolerance in both directions and log when you hit it, because a skewed signer turns into a mysterious 100 percent rejection rate.

If you are wiring agents to multiple model providers while you build this layer, Quanta Inference exposes text, image, video, audio and embedding models through one OpenAI-compatible API key with prepaid USD pricing, which keeps the model side of the pipeline behind a single endpoint while the approval and release logic stays in your own services.

FAQ

FAQ

Can I just hash the invoice number and call it an approval?

No. Hashing one field proves the invoice exists, not what will be paid or where. The signature has to cover the amount, currency, destination account and its version, plus an expiry and nonce, or a changed payee passes verification.

Where should the signing key live?

In a secrets manager, loaded by the service that mints tokens, never in client code or model context. If several services must verify, switch to an asymmetric signature so verifiers hold only a public key and rotation is a matter of publishing a new key ID.

What happens when a legitimate beneficiary change arrives after approval?

The signed payload stops matching, so the release is rejected and a re-approval task opens. That is the correct behavior: Payouts recommends gathering evidence on a separate channel for any destination change rather than carrying the old approval forward.

Is an expiry window enough to stop replay?

No, expiry only shrinks the window. You also need a nonce recorded as consumed in the same transaction that creates the payment, plus idempotency on the rail side where the provider supports it.

Should an AI agent ever approve a payment it prepared?

Not its own exception, and not a payment above the threshold you set for human sign-off. The agent can prepare and validate; a named person authorizes the signed payload, and a separate service verifies and releases it per the separation of preparation and exception approval described by Payouts.

Sources

Sources

  1. How to secure AI agents against financial fraud?
  2. LLM01:2025 Prompt Injection - OWASP Gen AI Security Project
  3. AI Agent Payments: Autonomy, Approvals, and Controls
  4. AI Agent for Approval Workflows: How It Works - Kissflow
  5. REST API security: HMAC/key hashing vs JWT
  6. Verification of Payee: Payee Match or Mismatch Explained
  7. Payment Agent Payee Verification
  8. AI Agent Spending Limits & Budget Controls - Locus
  9. How to Set Spending Limits for Your AI Agent - FluxA
  10. Payment Approval Workflow in Accounts Payable
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles