Card encryption with JWE

Card encryption with JWE

If you build your own card form instead of using our hosted components, the card details never travel to us in the clear. You encrypt them in the shopper's browser using JSON Web Encryption (JWE), then pass the resulting ciphertext to your server and on to the Woosa Payments API.
This page covers when to choose JWE, how to set it up, and what to watch out for in production.

Should you use this?

Most integrations should not. Reach for JWE only when you have a concrete reason to control the card form yourself.
Approach
PCI scope
Use when
Drop-in
SAQ A
You want a working checkout fast and are happy with our UI.
Components
SAQ A
You want your own layout, but our fields. Card data enters an iframe we host; your page never touches it.
JWE
SAQ A-EP or higher
You need the raw PAN in your own frontend, for example to run your own risk scoring, route between processors or BIN-check before submission.
The distinction that matters: with Components, the card number is typed into an iframe served from our domain, so your page is out of scope for the card data itself. With JWE, your JavaScript reads the PAN. That pulls your checkout page into PCI scope, and you become responsible for the integrity of every script loaded on it.
Before choosing JWE, confirm with your QSA or acquirer which SAQ applies to you. Moving from SAQ A to SAQ A-EP is a material increase in compliance obligation and it is not reversible without re-engineering your checkout.

How it works

Shopper's browser Your server Woosa Payments
───────────────── ─────────── ──────────────
1. Shopper types card
2. Encrypt with your
public key (JWK)
3. POST jwe ────────────────────►
4. POST /payments ──────►
5. Decrypt, authorise
◄─────────────────────── 6. Result
◄─────────────────────────────
The keypair is asymmetric. You hold the public half; the private half never leaves our infrastructure. Once a payload is encrypted, neither you nor your server can read it back — which is the point. If you need the PAN for your own risk logic, capture what you need before you encrypt.

Prerequisites

  • An active Woosa Payments merchant account.
  • API credentials with the Checkout role.
  • The Manage API credentials permission on your Woosa Payments dashboard user.
  • A working server-side /payments call. Get an unencrypted test payment working first, then add encryption, debugging both at once is unpleasant.

1. Download your X.509 certificate

Request the X.509 certificate from your Account Manager, we'll send it to you manually after confirming your PCI scope.
You will get a PEM file that looks like this:
-----BEGIN CERTIFICATE-----
MIIDXTCCAkWgAwIBAgIJAOSc...
-----END CERTIFICATE-----
The certificate is public. It is safe to serve it to browsers, embed it in your bundle, or commit it. That said, fetching it from your own backend at runtime is better practice: it lets you rotate without a redeploy.
Test and live accounts have different certificates. A very common failure is encrypting with the test certificate and posting to the live endpoint. The resulting error is not obvious, so check this first when decryption fails.

2. Import the certificate as a public key

We recommend  jose . It is actively maintained, has no dependencies, and works in browsers, Node and edge runtimes.
bash
npm install jose
js
import * as jose from 'jose';

const certificate = `-----BEGIN CERTIFICATE-----
MIIDXTCCAkWgAwIBAgIJAOSc...
-----END CERTIFICATE-----`;

const publicKey = await jose.importX509(certificate, 'RSA-OAEP-256');
Import the key once and reuse it. Re-importing on every keystroke or every submission is wasteful and will show up as jank on slower devices.

3. Build the payload

Collect the card fields into a plain object and add a timestamp.
js
const cardDetails = {
number: '4111111111111111',
expiryMonth: '03',
expiryYear: '2030',
cvc: '737',
generationtime: new Date().toISOString()
};
Field
Format
Notes
number
String, digits only
Strip spaces and dashes before encrypting.
expiryMonth
String, 01–12
Zero-padded. 3 is rejected; 03 is accepted.
expiryYear
String, four digits
2030, not 30.
cvc
String
Three digits, or four for American Express.
generationtime
ISO 8601 UTC
Must be recent. See below.
generationtime exists to stop replay of a captured payload. We reject blobs whose timestamp is too far from our server clock. Generate it at encryption time from the browser's clock, and encrypt at submission rather than on page load — a shopper who fills the form and then goes to make coffee will otherwise get a rejection on submit.
Do not log cardDetails, do not put it in application state that a debugger or error reporter might serialise, and do not send it to Sentry or similar. Session replay tools are the usual culprit here: audit your tag manager before go-live.

4. Encrypt

js
const jwe = await new jose.CompactEncrypt(
new TextEncoder().encode(JSON.stringify(cardDetails))
)
.setProtectedHeader({
alg: 'RSA-OAEP-256',
enc: 'A256GCM',
version: '1'
})
.encrypt(publicKey);
The result is a compact-serialisation JWE: five base64url segments separated by dots. It is safe to put in a JSON body.
Algorithms. Use RSA-OAEP-256 for key wrapping and A256GCM for content encryption. Older RSA-OAEP and A128CBC-HS256 are still accepted for backwards compatibility but should not be used in new integrations.
Encrypt everything in one blob. Encrypt the full object as a single payload rather than encrypting each field separately. Per-field encryption produces smaller, more uniform ciphertexts, which is exactly the shape an attacker wants for analysis.

5. Send to your server

js
await fetch('/api/checkout/pay', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jwe,
amount: 4999,
currency: 'EUR',
reference: orderId
})
});
Never send the amount, currency or reference from the client without validating them server-side against your own order record. The encrypted blob protects the card data; it does nothing for the rest of the request.

6. Make the payment

From your server, pass the blob through in the paymentMethod object:
bash
curl https://midlayer.woosa.nl/v1/payments \
-d '{
"merchantAccount": "YourMerchantAccount",
"amount": { "currency": "EUR", "value": 4999 },
"reference": "ORDER-10429",
"paymentMethod": {
"type": "scheme",
"jwe": "eyJhbGciOiJSU0EtT0FFUC0yNTYiLCJlbmMi..."
},
"returnUrl": "https://yourshop.com/checkout/return"
}'
Handle the response exactly as you would for any other card payment, including the 3D Secure action object when one is returned. JWE changes how card details reach us; it does not change authentication, and SCA still applies.

Testing

Use your test certificate with the standard test cards.
Card
Number
Result
Visa
4111 1111 1111 1111
Authorised
Mastercard
5555 4444 3333 1111
Authorised
Visa
4000 0000 0000 0002
Refused
Visa
4212 3456 7890 1237
3D Secure challenge
Any future expiry date and any CVC of the correct length will work in test.
Verify the blob decrypts before you go live. Encrypt a known test card, submit it, and confirm the payment authorises. A malformed JWE fails at decryption with a generic error, so proving the round trip on a card you control saves time later.

Troubleshooting

Unable to decrypt the provided data Almost always a certificate mismatch: test certificate against live endpoint, or a certificate from a different merchant account. Confirm the certificate you imported belongs to the credential making the API call.
Encrypted data is no longer valid generationtime is outside the accepted window. Move encryption to the submit handler. If it persists across many shoppers, suspect device clock drift rather than your code.
Invalid card details on a card you know is good Check field formats. expiryMonth unpadded and expiryYear in two-digit form are the two most frequent causes. Also check that you have stripped spaces from the card number — most input masks insert them.
Encryption succeeds locally, fails in production Compare the certificate loaded in each environment. If you fetch it at runtime, check that your production endpoint is returning the PEM intact — some proxies mangle the line breaks, which breaks importX509.
Cannot read properties of undefined from jose in the browser importX509 and CompactEncrypt().encrypt() both return promises. Missing an await gives this error a few lines later, where it is hard to trace back.

Security checklist before go-live

Content Security Policy restricts which scripts can run on the checkout page.
Subresource Integrity on any externally hosted script.
No session replay or error reporting tool captures form field values.
Card details are not written to localStorage, sessionStorage or any state store that persists.
The card object goes out of scope immediately after encryption.
Amount, currency and reference are validated server-side.
Certificate rotation does not require a frontend redeploy.
SAQ level confirmed with your QSA or acquirer.