All posts

Reverse Engineering CyberSource's tokenization

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..."
}

Checkout payload with the encrypted paymentKey and paymentToken

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:

FieldWhat it actually is
paymentKeyThe Capture Context JWT — a signed (RS256) token issued by CyberSource that carries the RSA public key and the tokenization endpoint
paymentTokenThe 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 get-payment-iframe-key request

The response is a single field — the Capture Context JWT:

{
  "keyId": "eyJraWQiOiJ3ZiIsImFsZyI6IlJTMjU2In0.eyJmbHgiOnsicGF0aCI6Ii..."
}

keyId response containing the Capture Context JWT

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 captured paymentToken fails — 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 plain RSA-OAEP, not RSA-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:

  1. OAEP hash is SHA-1. Go's rsa.EncryptOAEP(sha1.New(), ...) matches "alg":"RSA-OAEP". If you reach for SHA-256 you've implemented RSA-OAEP-256 and CyberSource will reject it.
  2. GCM tag handling. Go's Seal concatenates 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 = the jti from this response

Online vs offline tokenization

The PoC exposes two paths, and the choice matters for automation:

  • TokenizeCard() — does the live POST to flex.cybersource.com itself. 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 paymentToken is 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 kid changes, 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

  • paymentKey and paymentToken are plain JOSE: a Capture Context JWT and a tokenization-response jti.
  • The flow is: GET the context → decode its flx.jwk RSA key → build a RSA-OAEP + A256GCM JWE of the card → POST it → read jti.
  • 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 paymentToken is the most common failure.
  • Prefer the offline CreateCardJWE path 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.

Need a closed system understood and implemented? Explore our reverse engineering service.

About the authors

  • @blik2bankomatGitHub

    Co-Founder & Engineer at status403

    Builds and reverse engineers software systems, from low-level protocols and high-throughput network automation to production applications and infrastructure.

  • Co-Founder & Engineer at status403

    Builds and reverse engineers software systems, from low-level protocols and high-throughput network automation to production applications and infrastructure.