← Back to the tool

Payments · Under the hood

3DS message flow & JWS signing

What each button in the tool actually computes, step by step — with the real formulas.

The one part of 3DS that is fully public: the signing

Unlike CAVV generation, EMV 3DS message signing is a real, documented mechanism: the ACS signs its ARes as a compact JWS (RFC 7515), and the 3DS Server verifies it with the ACS's own public key. That's ordinary ECDSA over a JSON payload — something Web Crypto genuinely supports — so this tool, unlike the other three Payments tools, needs no from-scratch cipher at all.

A real P-256 key pair, generated in-browser
const acsKeyPair = await crypto.subtle.generateKey(
  { name: "ECDSA", namedCurve: "P-256" },
  true,
  ["sign", "verify"],
);

The same generateKey call the ECDSA tool uses elsewhere on this site — there's nothing payments-specific about the cryptography here, only about what gets signed.

Building the compact JWS by hand

A compact JWS is just base64url(header), a dot, base64url(payload), a dot, and base64url(signature), with the signature computed over the ASCII bytes of the first two segments joined by that dot. There's no library hiding this — the tool builds the string directly and signs it with crypto.subtle.sign.

Header, payload, and the signature that binds them
const header = { alg: "ES256", typ: "JOSE" };
const headerB64 = bufToBase64Url(utf8ToBuf(JSON.stringify(header)));
const payloadB64 = bufToBase64Url(utf8ToBuf(JSON.stringify(aresObject)));
const signingInput = `${headerB64}.${payloadB64}`;

const sig = await crypto.subtle.sign(
  { name: "ECDSA", hash: "SHA-256" },
  acsKeyPair.privateKey,
  utf8ToBuf(signingInput),
);
const jws = `${signingInput}.${bufToBase64Url(sig)}`;

Web Crypto's ECDSA sign() already returns the raw (r ‖ s) signature format JOSE/JWS expects — no DER-to-raw conversion step needed, unlike some other ECDSA ecosystems.

The risk decision this tool simplifies

"ACS: evaluate & sign ARes" uses one deliberately crude rule — amount ≥ 50 triggers a challenge — to stand in for what a real issuer's risk engine actually weighs: device fingerprint, cardholder history, merchant risk indicators, and dozens of other signals the EMV 3DS spec leaves entirely up to the issuer. The transStatus value this tool sets (Y for frictionless, C for challenge) is the real field from the real spec; only the logic that picks it is simplified.

Tamper, then verify

The tamper button edits the decoded ARes payload — flipping transStatus from C to Y, as an attacker trying to silently approve a transaction that should have required a step-up challenge would want to — and re-assembles a JWS using the original signature, which no longer matches the new payload. Verify recomputes the signature check independently and shows exactly where it fails.

Verification: recompute, don't decrypt
const valid = await crypto.subtle.verify(
  { name: "ECDSA", hash: "SHA-256" },
  acsPublicKey,
  signatureBytes,
  utf8ToBuf(`${headerB64}.${payloadB64}`),
);

Same pattern as every other signature scheme on this site: the verifier recomputes over what it actually received and compares, it never decrypts the signature back into anything.

Now try it

Back to 3DS message flow & JWS signing →

Build a real EMV 3DS 2.x AReq, watch the ACS decide frictionless vs. challenge, sign a real ARes as a JWS with ECDSA, then verify it — or tamper with it and watch the signature catch it.