Introduction
CyberSource is Visa's payment gateway. Merchants hand it card processing, fraud screening (Decision Manager) and tokenization (Flex Microform), so a raw card number never touches the merchant's own servers. PokemonCenter runs its checkout on it — which is where the encrypted fields below come from.
When adding a credit card during PokemonCenter checkout a request is fired which contains encrypted fields:
{
"paymentKey": "eyJraWQiOiJ3ZiIsImFsZyI6IlJTMjU2In0.eyJmbHgiOnsicGF0aCI6Ii...",
"paymentToken": "1C5M4PX1601QB22Q..."
}

Neither field is something the browser invented. They are the output of CyberSource Flex Microform — the tokenization layer CyberSource ships to merchants so raw PAN data never touches the merchant's own backend. If you want to automate this checkout, you cannot just replay these values: paymentToken is bound to a single short-lived encryption context that expires in minutes.
This post walks the entire flow end to end and shows how to reproduce it offline. A full, working Go PoC lives at status403com/cybersource_encryption — we reference it throughout.
⚠️ This is educational reverse-engineering of a payment tokenization scheme. It tokenizes card data you already own against a merchant's public key — it does not break encryption, forge tokens, or bypass authorization. Respect PCI scope, merchant ToS, and the law.
The two encrypted fields
Decode either token at jwt.io and the structure gives the game away. They are not opaque blobs — they are standard JOSE:
| Field | What it actually is |
|---|---|
paymentKey | The Capture Context JWT — a signed (RS256) token issued by CyberSource that carries the RSA public key and the tokenization endpoint |
paymentToken | The jti claim extracted from the tokenization response — an opaque reference to the encrypted card held by CyberSource |
So the checkout request is really saying: "here is the context I encrypted under (paymentKey), and here is the token CyberSource gave me back (paymentToken)." The merchant's server forwards both to CyberSource, which resolves the token back to the real card on its side. Reproduce those two values and the checkout believes a human typed a card into the iframe.
Step 1 — Fetch the Capture Context
Everything is seeded by one request. Before the card form is usable, the page calls a merchant endpoint that proxies CyberSource's "capture context" generation:
GET /tpci-ecommweb-api/get-payment-iframe-key?microform=true&locale=en-GB
Host: www.pokemoncenter.com

The response is a single field — the Capture Context JWT:
{
"keyId": "eyJraWQiOiJ3ZiIsImFsZyI6IlJTMjU2In0.eyJmbHgiOnsicGF0aCI6Ii..."
}

Every merchant exposes this differently — different path, different parameter name — but the value is always the same shape: a three-part JWT. On Pokémon Center it's keyId; that exact string is what later gets sent back as paymentKey.
Step 2 — Decode the Capture Context
The JWT's RS256 signature is CyberSource's, so we don't verify it — we only need to read it. Its payload carries everything required to encrypt:
{
"flx": {
"path": "/flex/v2/tokens",
"origin": "https://flex.cybersource.com",
"jwk": {
"kty": "RSA",
"kid": "00…",
"use": "enc",
"n": "<base64url modulus>",
"e": "AQAB"
}
},
"ctx": [ … ],
"iss": "Flex/08",
"exp": 1718000000,
"iat": 1718000000,
"jti": "…"
}
The important bits:
flx.jwk— the RSA public key (n,e) we encrypt the card under. This is the key, rotated frequently and pinned per-context.flx.origin+flx.path— the tokenization endpoint (https://flex.cybersource.com/flex/v2/tokens).exp— the context is short-lived. Encrypt against an expired context and CyberSource rejects it. This is the single biggest reason replaying a capturedpaymentTokenfails — you must mint a fresh one each run.
In the PoC, parsing is just splitting on . and base64url-decoding the first two segments:
func ParseCaptureContext(jwt string) (*CaptureContext, error) {
parts := strings.Split(jwt, ".")
if len(parts) != 3 {
return nil, fmt.Errorf("invalid JWT format: expected 3 parts, got %d", len(parts))
}
headerBytes, _ := base64URLDecode(parts[0])
payloadBytes, _ := base64URLDecode(parts[1])
var headers map[string]interface{}
json.Unmarshal(headerBytes, &headers)
var payload CaptureContextPayload
json.Unmarshal(payloadBytes, &payload)
return &CaptureContext{
Headers: headers,
Payload: payload, // payload.Flx.JWK holds the RSA public key
Signature: parts[2],
Raw: jwt,
}, nil
}
The JWK is then turned into a Go RSA key by reading the modulus and exponent straight out of the base64url fields:
func jwkToRSAPublicKey(jwk JWK) (*rsa.PublicKey, error) {
if jwk.Kty != "RSA" {
return nil, fmt.Errorf("unsupported key type: %s", jwk.Kty)
}
nBytes, _ := base64URLDecode(jwk.N)
eBytes, _ := base64URLDecode(jwk.E)
n := new(big.Int).SetBytes(nBytes)
e := 0
for _, b := range eBytes {
e = e<<8 + int(b)
}
return &rsa.PublicKey{N: n, E: e}, nil
}
Step 3 — What the Microform really does: it builds a JWE
The CyberSource Flex Microform iframe — the thing that "securely captures" the card — is, under the hood, a JWE (JSON Web Encryption) generator. When you type a card and submit, the iframe encrypts a small JSON document to CyberSource's public key and posts it to /flex/v2/tokens. That's the whole secret. No WASM, no obfuscated VM — standard JOSE crypto.
The plaintext it encrypts looks like this — the card, plus the raw Capture Context echoed back so CyberSource can bind the two together:
{
"data": { "CARD": {
"number": "4111111111111111",
"securityCode": "123",
"type": "001",
"expirationMonth": "12",
"expirationYear": "2026"
}},
"context": "<the full capture context JWT>",
"index": 0
}
The exact crypto
The JWE uses one specific algorithm pair, and getting it wrong means a 400 from CyberSource:
- Key wrapping:
RSA-OAEP(with SHA-1 — the JOSE default for plainRSA-OAEP, notRSA-OAEP-256) - Content encryption:
A256GCM(AES-256 in GCM mode) - Serialization: JWE compact —
header.encryptedKey.iv.ciphertext.tag - AAD: the base64url-encoded protected header (GCM's additional authenticated data)
Here is the core of CreateJWE from the PoC, which is a faithful reimplementation of what the iframe's JS does:
// 2. JWE header — note RSA-OAEP + A256GCM, kid from the context's JWK
header := JWEHeader{
Kid: captureContext.Payload.Flx.JWK.Kid,
Alg: "RSA-OAEP",
Enc: "A256GCM",
}
headerB64 := base64URLEncode(mustMarshal(header))
// 3. Random 256-bit Content Encryption Key + 4. 96-bit IV
cek := make([]byte, 32)
rand.Read(cek)
iv := make([]byte, 12)
rand.Read(iv)
// 5. Wrap the CEK with RSA-OAEP(SHA-1) under CyberSource's public key
pubKey, _ := jwkToRSAPublicKey(captureContext.Payload.Flx.JWK)
encryptedKey, _ := rsa.EncryptOAEP(sha1.New(), rand.Reader, pubKey, cek, nil)
// 6. Encrypt the card JSON with AES-256-GCM, AAD = the header
block, _ := aes.NewCipher(cek)
aesGCM, _ := cipher.NewGCM(block)
aad := []byte(headerB64)
ciphertextWithTag := aesGCM.Seal(nil, iv, plaintext, aad)
// GCM appends the 16-byte tag — split it back out for JOSE
tag := ciphertextWithTag[len(ciphertextWithTag)-16:]
ciphertext := ciphertextWithTag[:len(ciphertextWithTag)-16]
// 7. JWE compact serialization
jwe := fmt.Sprintf("%s.%s.%s.%s.%s",
headerB64,
base64URLEncode(encryptedKey),
base64URLEncode(iv),
base64URLEncode(ciphertext),
base64URLEncode(tag),
)
Two details that trip people up:
- OAEP hash is SHA-1. Go's
rsa.EncryptOAEP(sha1.New(), ...)matches"alg":"RSA-OAEP". If you reach for SHA-256 you've implementedRSA-OAEP-256and CyberSource will reject it. - GCM tag handling. Go's
Sealconcatenates the tag onto the ciphertext; JOSE wants them as separate compact segments. You must slice off the last 16 bytes.
Step 4 — Tokenize and pull the jti
Post the JWE to the endpoint from the context, with the JOSE content type. CyberSource returns another JWT — the tokenization receipt:
func (fc *FlexClient) CreateToken(ctx *CaptureContext, jwe string) (string, error) {
url := ctx.GetTokenURL() // flx.origin + flx.path
req, _ := http.NewRequest("POST", url, bytes.NewBufferString(jwe))
req.Header.Set("Content-Type", "application/jwt; charset=utf-8")
resp, err := fc.httpClient.Do(req)
// ... return body (the response JWT) ...
}
Decode that response JWT and the jti claim is the paymentToken:
func parsePaymentToken(encoded string) (string, error) {
token, _, err := jwt.NewParser().ParseUnverified(encoded, jwt.MapClaims{})
if err != nil {
return "", err
}
jti, ok := token.Claims.(jwt.MapClaims)["jti"].(string)
if !ok {
return "", fmt.Errorf("payment token not found")
}
return jti, nil
}
That's the loop closed. You now hold both checkout fields:
paymentKey= the Capture Context JWT from Step 1 (sent back verbatim)paymentToken= thejtifrom this response
Online vs offline tokenization
The PoC exposes two paths, and the choice matters for automation:
TokenizeCard()— does the livePOSTtoflex.cybersource.comitself. Simplest, but it adds a request from your infrastructure to CyberSource. For bot work you'll want proxy support so that call isn't fingerprinted separately from the rest of your session.CreateCardJWE()— builds the JWE and stops. You hand the JWE to the merchant flow / your own HTTP client and never touch CyberSource directly. This keeps your network signature identical to the browser's and is the cleaner option when you already control the request stack (e.g. paired with tls-client for a browser-accurate handshake).
client := NewFlexClient()
card := CardDetails{
Number: "4111111111111111",
SecurityCode: "123",
Type: DetermineCardType("4111111111111111"), // "001" = Visa
ExpirationMonth: "12",
ExpirationYear: "2026",
}
// One-shot online tokenization:
token, _ := client.TokenizeCard(captureContextJWT, card)
paymentToken, _ := parsePaymentToken(token)
// ...or offline: build the JWE and route it yourself
jwe, _ := client.EncodeCard(captureContextJWT, card)
Where the "AntiFraud" actually lives
It's worth being precise about what this scheme does and doesn't protect against. CyberSource Flex is tokenization, not a behavioral anti-bot. Its security properties are:
- PAN isolation — the merchant (and your automation) never needs to be PCI-scoped for the raw card; only CyberSource sees it.
- Context binding + expiry — the
paymentTokenis meaningless without a live Capture Context, and the context dies in minutes. This is what defeats naive replay: you can't capture one token and reuse it tomorrow. - Key rotation — the JWK
kidchanges, so hardcoding a key gets you nowhere.
What it does not do is stop a client that performs the flow correctly — fetch a fresh context, encrypt properly, tokenize, submit. The real fraud signals (device fingerprint, velocity, the broader risk decision) live in CyberSource Decision Manager and the merchant's own anti-bot stack — which is exactly why the TLS and HTTP/2 layer from the previous post still matters. Tokenization is the part you have to get cryptographically right; it is not the part deciding whether you look like a bot.
Key takeaways
paymentKeyandpaymentTokenare plain JOSE: a Capture Context JWT and a tokenization-responsejti.- The flow is:
GETthe context → decode itsflx.jwkRSA key → build aRSA-OAEP+A256GCMJWE of the card →POSTit → readjti. - The crypto is exact and unforgiving: SHA-1 OAEP, AES-256-GCM, header-as-AAD, and you must split GCM's 16-byte tag into its own JOSE segment.
- Contexts expire — mint a fresh one per attempt; replaying a stale
paymentTokenis the most common failure. - Prefer the offline
CreateCardJWEpath so your network fingerprint stays coherent with the rest of the session.
Full reference implementation: status403com/cybersource_encryption.
This kind of clean-room reconstruction is the reverse engineering work we do as a service. Need a payment or anti-fraud flow reverse engineered cleanly? Get in touch.