Reference
Verdict test vectors
138 tokens with known answers for each of the twelve verification steps and for checking a stored verdict, signed with published test keys.
These vectors test a verifier against the checks a relying party applies before it settles on a verdict, and against the checks an auditor applies to a stored one. There are 138: 24 must be accepted and 114 rejected. Each comes with everything a verifier needs: the key sets, the clock, the payment record, the nonces already seen and, where it matters, the verifier's own settings.
Section numbers in the descriptions refer to draft-dias-agent-payment-verdicts-00, the Internet-Draft that describes the verdict format and its verification procedure.
Download: verdict-test-vectors.json
Test keys only
Every signature here is made with, or derived from, test keys whose private halves are in the same file, published on purpose so that anyone can reproduce and extend the vectors. The issuers are the reserved example domains https://issuer.example and https://issuer-b.example, not Saifuro. A token signed with these keys proves nothing. Never add these keys or key sets to a verifier that protects real payments.
Step numbers in the vectors refer to this order. A verifier may run the checks in another order, as long as it verifies the signature before it relies on any claim and records the nonce last; it can then report a different first failure, so expected.valid must match and expected.step should.
| Step | Check | What it rejects |
|---|
| 1 | Parse | Bound the size; exactly three unpadded base64url segments; UTF-8; JSON objects; no duplicate member names. |
| 2 | Header | alg exactly ES256; no crit; kid a non-empty string; no keys taken from jku, jwk, x5u or x5c. |
| 3 | Environment | Reject a kid that begins with sandbox- unless the verifier is used only for testing. |
| 4 | Issuer and key | iss exactly one of your configured issuers; the kid found exactly once, in that issuer's key set only, after at most one rate-limited refetch; an EC P-256 key with alg ES256 and use sig if present. |
| 5 | Signature | A 64-byte R||S signature, R and S in [1, n-1], valid for the key. High S is accepted. |
| 6 | Token kind and structure | Not an event token; claim types and presence; aud only if it names you. |
| 7 | Time | Not at or after exp + 60 s; iat not more than 60 s ahead; not before nbf - 60 s. |
| 8 | Decision and operator | verdict is allow, under a mandate the operator agreed with you. |
| 9 | Amount | Equal to your payment record by decimal value; as binary64 only when both amounts are below 2^30 with at most six fractional digits. |
| 10 | Currency | Equal to your record, case-sensitive. |
| 11 | Recipient | Equal to your record, code point for code point. |
| 12 | Replay | The pair of iss and nonce (or of iss and jti) not seen before; then recorded. |
A stored verdict checked as a record ("mode": "record") goes through steps 1, 2, 4, 5 and 6, step 3 only if the checking party separates test verdicts, and step 7 against the time the verdict was received if that time was recorded, and otherwise not at all. Steps 8 to 12 do not apply, and a key missing from the stored copy of the key set is a failure: there is no refetch.
For each vector, configure your verifier with:
- the accepted issuers in
issuers, each mapped to its key set in keySets. A kid that is not in the set stays absent after your one refetch;
- the clock pinned to
now, with a clock skew allowance of exactly 60 seconds (several vectors sit on that boundary);
- in settle mode: the payment record in
payment, already converted to the unit agreed with the operator and to the issuer's form of the currency code (the amount is a decimal string), the mandate ids in agreedMandates, the stores seenNonces (or seenJtis, if your verifier records jti) and seenSupersedes, and testingOnly;
- in record mode:
receivedAt, distinguishTest and, where present, refetchWouldReturn, the set a refetch would return (a record checker must not refetch);
audience, your own identifier for the aud rule, or none;
- a JSON nesting-depth bound of at least 4.
The expected results assume your verifier applies every check that is recommended rather than required; the vectors that depend on one say so.
A production verifier never pins its clock. The vectors pin it so that the answers do not change with the date.
ECDSA signatures are randomized, so the vectors cannot be regenerated byte for byte; the file is the reference.
| Id | What it checks | Expected |
|---|
segment-padded | The payload segment keeps its base64 padding (=), and the signature covers the padded segment. Many base64 decoders accept padding; step 1 rejects it. | reject |
segment-whitespace | A line break inside the payload segment, covered by the signature. | reject |
segment-std-alphabet | The payload segment uses '+' or '/' from the standard base64 alphabet instead of '-' and '_', and the signature covers it as written. | reject |
segment-length-1-mod-4 | A header segment whose length is 1 modulo 4, covered by the signature. No base64 encoding has that length; some decoders drop the extra character. | reject |
two-segments | Only header and payload, no signature segment. | reject |
five-segments | Five segments, shaped like a JWE, with a readable payload in the ciphertext position. | reject |
payload-invalid-utf8 | The payload contains a byte that is not valid UTF-8 inside the recipient; the signature covers it. The payment record's recipient ends in U+FFFD, so a decoder that substitutes U+FFFD instead of rejecting accepts it. | reject |
payload-array | The payload is a JSON array holding the claims object, not an object. | reject |
duplicate-claim-iss | The payload has iss twice: the configured issuer first, then another. A verifier whose parser keeps the lexically last member rejects it at step 4 instead. | reject |
duplicate-member-recipient | request has recipient twice: the payment's payee first, then another. Rests on step 1's SHOULD to reject duplicates at any depth (a MUST for a verifier that parses the payload more than once). A parser that keeps the last member rejects it at step 11; one that keeps the first member and does not apply the SHOULD accepts it. | reject |
duplicate-header-kid | The header has kid twice: example-1 first, then sandbox-example-1, whose key signed the token. A parser that keeps the last member rejects it at step 3; one that keeps the first, which step 1 forbids, rejects it at step 5. | reject |
header-array | The header is a JSON array, not an object. | reject |
| Id | What it checks | Expected |
|---|
alg-none | alg "none" and an empty signature. | reject |
alg-hs256-key-confusion | alg HS256 with the SPKI PEM of example-1 (with its trailing newline) as the HMAC secret. | reject |
alg-lowercase | alg written "es256"; the signature is a valid ES256 signature. | reject |
alg-es384-label | alg "ES384" on a token whose signature is ES256. | reject |
header-crit | A header with a crit parameter. | reject |
kid-missing | No kid header parameter. | reject |
kid-empty | kid is the empty string. | reject |
alg-missing | No alg header parameter. | reject |
kid-not-string | kid is a number. | reject |
header-jwk | Signed with an attacker's key and carrying a jwk header parameter, next to the kid of a published key. Keys never come from the token. A verifier that ignores the parameter instead of rejecting it (a SHOULD in step 2) rejects the token at step 5. | reject |
header-jku | Signed with an attacker's key and carrying a jku header parameter, next to the kid of a published key. Keys never come from the token. A verifier that ignores the parameter instead of rejecting it (a SHOULD in step 2) rejects the token at step 5. | reject |
header-x5u | Signed with an attacker's key and carrying a x5u header parameter, next to the kid of a published key. Keys never come from the token. A verifier that ignores the parameter instead of rejecting it (a SHOULD in step 2) rejects the token at step 5. | reject |
header-x5c | Signed with an attacker's key and carrying a x5c header parameter, next to the kid of a published key. Keys never come from the token. A verifier that ignores the parameter instead of rejecting it (a SHOULD in step 2) rejects the token at step 5. | reject |
| Id | What it checks | Expected |
|---|
sandbox-key-production | Signed with the test key sandbox-example-1, checked by a verifier that protects real payments. | reject |
| Id | What it checks | Expected |
|---|
iss-trailing-slash | iss is https://issuer.example/ (trailing slash). | reject |
iss-case | iss is https://Issuer.example (case differs). | reject |
iss-missing | No iss claim. | reject |
iss-not-configured | iss names an issuer the verifier does not accept; the token is signed with example-1. | reject |
kid-unknown | Signed with example-2, whose kid is in no key set. A refetch returns the same set. | reject |
kid-from-other-issuer | The verifier accepts both issuers. iss is https://issuer.example, but kid and signature belong to https://issuer-b.example's key b-1. The key is looked up only in the set of the issuer named by iss. | reject |
jwks-duplicate-kid | The key set lists kid example-1 twice with different keys; the token is signed with the second (example-2's key). A verifier that takes the first match rejects it at step 5; one that tries every match would accept it. | reject |
jwks-key-alg-es384 | The key set entry for example-1 has alg ES384. | reject |
jwks-key-use-enc | The key set entry for example-1 has use enc. | reject |
jwks-key-crv-p384 | The key set entry for example-1 is a P-384 key. | reject |
| Id | What it checks | Expected |
|---|
jwks-key-off-curve | The key set entry for example-1 has y increased by one, so the point is not on P-256. Step 5 requires an implementation that validates the public key. | reject |
tampered-amount | The payload's amount changed from 340 to 3400 after signing; the payment record says 3400. | reject |
signature-der | The signature re-encoded as ASN.1 DER. | reject |
signature-63-bytes | A valid signature whose R begins with a zero byte, written with that byte removed (63 bytes). A verifier that splits at half the length or pads with zeros would accept it. | reject |
signature-zero | R and S are both zero (CVE-2022-21449). | reject |
signature-r-equals-n | R is n, the group order; S is from a valid signature. | reject |
signature-s-equals-n | S is n, the group order; R is from a valid signature. | reject |
signature-wrong-key | kid example-1, signed with example-2. | reject |
| Id | What it checks | Expected |
|---|
event-token | A webhook event token: same key, issuer and header; a type claim, a jti beginning "evt_", no exp; data.verdict is allow. | reject |
type-claim | A complete allow that also carries a type claim. | reject |
jti-evt-prefix | A complete allow whose jti begins "evt_". | reject |
jti-missing | No jti claim. | reject |
jti-empty | jti is the empty string. | reject |
iat-fraction | iat is 1791374400.5, a NumericDate that is not an integer. | reject |
iat-string | iat is a string. | reject |
exp-missing | No exp claim. | reject |
iat-missing | No iat claim. | reject |
exp-equals-iat | exp equals iat (both 30 s after the verifier's clock, so step 7 alone would pass it). | reject |
verdict-missing | No verdict claim. | reject |
verdict-empty | verdict is the empty string. | reject |
policy-version-missing | No policy_version claim. | reject |
nonce-missing | No nonce claim. | reject |
nonce-empty | nonce is the empty string. | reject |
request-missing | No request claim. | reject |
request-array | request is an array. | reject |
amount-string | request.amount is the string "340". | reject |
amount-zero | request.amount is 0. | reject |
amount-negative | request.amount is -340. | reject |
currency-missing | request has no currency member. | reject |
currency-empty | request.currency is the empty string. | reject |
recipient-empty | request.recipient is the empty string. | reject |
agent-missing | request has no agent member. | reject |
category-missing | request has no category member. | reject |
recipient-missing | request has no recipient member. | reject |
amount-missing | request has no amount member. | reject |
agent-empty | request.agent is the empty string. | reject |
allow-without-mandate | An allow with no mandate claim. | reject |
mandate-empty | An allow whose mandate is the empty string. | reject |
supersedes-on-review | A review verdict that carries supersedes. A verifier that checks the code first rejects it at step 8. | reject |
aud-other | aud names another relying party; the verifier identifies itself as https://rp.example. | reject |
aud-no-identity | aud is present, and the verifier has no identifier configured. | reject |
aud-array-no-match | aud is an array of other relying parties; the verifier identifies itself as https://rp.example. | reject |
| Id | What it checks | Expected |
|---|
expired | Checked at exp + 60 s, the end of the allowance. | reject |
iat-ahead-beyond-skew | iat is 61 s after the verifier's clock. | reject |
iat-in-future | iat is an hour after the verifier's clock. | reject |
nbf-beyond-skew | nbf is 61 s after the verifier's clock. | reject |
| Id | What it checks | Expected |
|---|
review | A review verdict: signed, not an approval. | reject |
deny-limit | A deny_limit verdict. | reject |
deny-no-mandate | A deny_no_mandate verdict, which has no mandate claim. | reject |
unknown-code | A verdict code this document does not define. | reject |
allow-capitalized | verdict is "Allow". | reject |
mandate-not-agreed | An allow under mnd_51c0e2aa, a mandate the operator has not agreed with this relying party (it agreed mnd_7f3a0d12). | reject |
| Id | What it checks | Expected |
|---|
amount-mismatch | An allow for 340 against a payment of 340.50. | reject |
amount-binary64-collision | An allow for 8589934592.000001 against a payment of 8589934592.000002 (Section 4.6). Both parse to the same binary64 value. | reject |
amount-sixteen-digits | An allow for 9007199254740993, which has more digits before the decimal point than the issuer writes, against 9007199254740992; the two are one binary64 value. A verifier that applies the amount-form check of step 9 where it first converts the amount, for the comparison with zero in step 6, reports step 6. | reject |
amount-huge-exponent | request.amount is 1e400000. A verifier rejects it without expanding it. A verifier that applies the amount-form check of step 9 where it first converts the amount, for the comparison with zero in step 6, reports step 6; one whose JSON parser refuses numbers outside the binary64 range reports step 1. | reject |
amount-form-exponent | An allow whose amount is written with an exponent (3.4E2), against a payment record of the same decimal value. Rests on the SHOULD of step 9: the amount is equal by decimal value, so only the check of the form the issuer writes rejects it. | reject |
amount-form-seven-decimals | An allow whose amount is with seven fractional digits, against a payment record of the same decimal value. Rests on the SHOULD of step 9: the amount is equal by decimal value, so only the check of the form the issuer writes rejects it. | reject |
amount-form-sixteen-digits | An allow whose amount is with sixteen digits before the decimal point, against a payment record of the same decimal value. Rests on the SHOULD of step 9: the amount is equal by decimal value, so only the check of the form the issuer writes rejects it. | reject |
| Id | What it checks | Expected |
|---|
currency-mismatch | An allow in USD against a payment in EUR. | reject |
currency-case | An allow in USD against a payment record that says usd after any conversion of Section 4.3. | reject |
| Id | What it checks | Expected |
|---|
recipient-mismatch | An allow for vendor:acme_saas against a payment to vendor:acme_saas_eu. | reject |
recipient-normalization | The token's recipient is in NFC, the payment record's in NFD; no normalization. | reject |
recipient-trailing-space | The payment record's recipient has a trailing space. | reject |
recipient-zero-width | The token's recipient contains a zero-width space. | reject |
recipient-case | The payment record's recipient differs only in case. | reject |
| Id | What it checks | Expected |
|---|
replayed-nonce | The token was accepted before: its pair of iss and nonce (and of iss and jti) is already in the store. | reject |
low-s-twin-replay | The low-S twin of the Appendix A token, after the Appendix A token was accepted. The replay key is a claim value, not the token string. | reject |
replayed-supersedes | A second allow for the same review: its supersedes value was already accepted (Section 10.12). Rests on Section 10.12, a SHOULD: a verifier that does not apply it accepts this token, whose own nonce and jti are new. | reject |
| Id | What it checks | Expected |
|---|
appendix-a | The token of Appendix A.2, checked as Appendix A.3 describes. | accept |
appendix-a-low-s-twin | The Appendix A token with S replaced by n - S. Both signatures are valid (step 5 forbids requiring low S); the claims, and so the nonce, are the same. | accept |
allow-decimal-equal | request.amount 12.5 against a payment record of 12.50: equal by decimal value (step 9). | accept |
allow-below-one | request.amount 0.5 against 0.500000. | accept |
allow-max-precision | request.amount 999999999999999.999999, the largest amount the issuer writes, against the same amount. Equal by decimal value. The amount is above 2^30, so a binary64 comparison is not permitted; a verifier that reads the claim as binary64 (1000000000000000) and compares that with the record's decimal value rejects this valid verdict. | accept |
allow-supersedes | An allow from an operator's approval of a review: supersedes names the review verdict. | accept |
allow-unknown-members | An allow with a private claim and a member of request that this document does not define; verifiers ignore both (Section 4.2). | accept |
sandbox-key-testing-only | Signed with the test key sandbox-example-1, checked by a verifier used only for testing (step 3). | accept |
expired-within-skew | Checked at exp + 59 s: inside the 60-second allowance (step 7). | accept |
iat-ahead-within-skew | iat is 60 s after the verifier's clock: at the limit of the allowance (step 7). | accept |
nbf-within-skew | nbf is 60 s after the verifier's clock: not rejected (step 7). | accept |
aud-array-matching | aud is an array that contains the verifier's identifier (step 6). | accept |
aud-string-matching | aud is a string equal to the verifier's identifier (step 6). | accept |
recipient-unicode-exact | A non-ASCII recipient that matches the payment record code point for code point. | accept |
second-issuer | A verifier that accepts two issuers; the token comes from the second, with that issuer's key. | accept |
nonce-seen-for-other-issuer | The nonce store holds the same nonce for another issuer; the replay key is the pair of iss and nonce (step 12). | accept |
allow-reordered-escaped | A valid allow whose payload lists members in another order, has whitespace, and writes iss and recipient with JSON escapes (\/ and \u0061). Verifiers MUST NOT depend on member order, whitespace or escaping (Section 4.1). | accept |
typ-absent | A valid allow whose header has no typ. Verifiers do not use typ (step 2). | accept |
production-key-testing-only | Signed with example-1, a production key, checked by a verifier used only for testing. Step 3 rejects test keys outside testing, not production keys inside it. | accept |
jwks-key-minimal | A valid allow checked against a key set entry with no alg and no use member; both are checked only if present (step 4). | accept |
| Id | What it checks | Expected |
|---|
record-refusal | A deny_limit verdict checked as a record, with the time it was received. | accept |
record-allow-expired-later | An allow checked as a record a month later; it was received while valid. | accept |
record-no-receipt-time | A deny_no_mandate verdict checked as a record with no recorded receipt time: step 7 is not applied. | accept |
record-received-late | An allow checked as a record; it was received at exp + 60 s. | reject at step 7 |
record-key-not-in-copy | A stored key-set copy that lacks example-1. No refetch for stored verdicts. | reject at step 4 |
record-tampered | A deny_limit record whose verdict was changed to allow after signing. | reject at step 5 |
record-test-verdict | A test-environment verdict checked by a party that distinguishes test verdicts. | reject at step 3 |
record-test-verdict-not-distinguished | The same kind of test-environment verdict, checked as a record by a party that does not separate test verdicts (Section 6.2 applies step 3 only if it needs to). | accept |
record-allow-without-mandate | Checked as a record: an allow with no mandate claim. Step 8 does not apply to records, so only step 6 catches it. | reject at step 6 |
record-mandate-empty | Checked as a record: an allow whose mandate is the empty string. Step 8 does not apply to records, so only step 6 catches it. | reject at step 6 |
record-supersedes-on-review | Checked as a record: a review verdict that carries supersedes. Step 8 does not apply to records, so only step 6 catches it. | reject at step 6 |
record-verdict-missing | Checked as a record: a token with no verdict claim. Step 8 does not apply to records, so only step 6 catches it. | reject at step 6 |
record-verdict-empty | Checked as a record: a token whose verdict is the empty string. Step 8 does not apply to records, so only step 6 catches it. | reject at step 6 |