Cybersource JWT Authentication with Message-Level Encryption (MLE)

Cybersource REST API requests are signed with a per-request JWT, and the payloads can optionally be wrapped in message-level encryption (MLE). Authentication and encryption code is unforgiving by nature: one wrong byte in a signature, digest, or ciphertext fails the whole request, and the resulting errors (401s, digest mismatches, “unable to decrypt”) rarely say which piece is at fault. That makes it hard to troubleshoot.

This guide walks through it in the order you’d build it up, anchored to the two .p12 files you use with the API. The tabs above let you inspect your own artifacts as you read. Everything here is also implemented in a small open-source library, if you prefer to read working code:

The big picture: two p12 files, two jobs

You generate two keys in Cybersource and download each as a PKCS#12 (.p12) keystore. Almost everything else follows from understanding what’s inside each one and the job it does.

The request p12 does two jobs. It holds:

  • Your signing key and certificate. The private key signs every request’s JWT (this is authentication), and the certificate’s subject serialNumber becomes the JWT’s kid. The certificate’s common name is your merchant ID (or your portfolio ID for a meta key).
  • Cybersource’s public MLE certificate, common name CyberSource_SJC_US. You use its public key to encrypt request bodies, so that only Cybersource can read them.

The response p12 does one job. It holds your private decryption key, used to read the encrypted response Cybersource sends back.

You’ll also see a CA (issuer) certificate in the bundle. That’s just the authority that vouches for the certs; you don’t sign or encrypt with it. The p12 Explorer tab opens either keystore and labels each certificate by common name, serialNumber / kid, validity, and role, so you can confirm which key is which before you go further.

From those two files come the three pieces this guide steps through, in order:

  1. Authenticate every call with a signed JWT, the baseline required on every request.
  2. Encrypt the request (request MLE), using Cybersource’s cert from the request p12.
  3. Decrypt the response (response MLE), using your key from the response p12.

Authentication is required on every call. MLE is an optional feature you can apply to the request, the response, or both; this guide covers all three pieces so you have the full path when you use it.

Piece 1: Authentication (the signed JWT)

This is the baseline. Every Cybersource REST call carries a freshly signed JWT in the Authorization: Bearer header. It identifies you (only you can produce a valid signature with your private key) and proves the request body wasn’t altered in transit (via a digest claim, below). Get this right and you can authenticate; everything after this layers on top.

The JWT is signed RS256 (RSA with SHA-256) with the signing key from your request p12. Two header fields matter:

  • alg is RS256.
  • kid is the serialNumber attribute from your signing certificate’s subject. Note that this is not the X.509 serial number, and not your merchant ID. The p12 Explorer tab shows you exactly which value this is.

The JWT payload carries the claims the API requires. For a POST request the claim set looks like this:

{
  "iat": 1718380800,
  "exp": 1718380920,
  "request-host": "apitest.cybersource.com",
  "request-resource-path": "/tms/v2/tokenize",
  "request-method": "post",
  "iss": "your_merchant_id",
  "jti": "f7c1bec0-189c-472a-afe8-c55b1a019c56",
  "v-c-jwt-version": "2",
  "v-c-merchant-id": "your_merchant_id",
  "digest": "ilXZXw+yriDTJxzXbPdvFvEhsEhNFiXT2lIQ6d9GlYU=",
  "digest-algorithm": "SHA-256"
}

A few details to get right:

  • exp is two minutes after iat, so a token is valid for two minutes. Clock skew beyond that fails authentication, a common cause of intermittent 401s.
  • request-method is lowercase, and request-resource-path is the path only (no host, no query string).
  • iss and v-c-merchant-id are your merchant ID, unless you use a meta key (covered below).

The digest is just one of these claims

The digest and digest-algorithm claims are how the JWT binds itself to the body: digest is a SHA-256 hash of the request body, standard Base64-encoded, and digest-algorithm names the hash. Because it’s signed inside the JWT, the server can confirm the body it received matches the one you signed. There’s no separate digest header to manage; it lives in the token.

Two rules cover almost every digest mismatch:

  • It covers the exact bytes you send. Re-serialize the JSON any differently from what goes on the wire (key order, whitespace, a trailing newline) and the digest won’t match.
  • On a body-less method it isn’t there at all. digest and digest-algorithm appear only on body methods (POST, PUT, PATCH); GET and DELETE omit them.

At this baseline (no encryption yet) the digest covers your plaintext JSON body. Request MLE changes which bytes that is, which is the next piece.

Paste a token into the JWT / JWE Decoder tab to break down the header and claims and check that every required one is present, and use the Digest Calculator tab to confirm the digest claim matches your body byte-for-byte.

Piece 2: Request MLE (encrypting what you send)

This is the request p12’s second job. MLE wraps your request body in a JWE (JSON Web Encryption), adding encryption on top of TLS so the payload is unreadable even to anything terminating TLS between you and Cybersource. You encrypt to Cybersource’s public MLE certificate (CyberSource_SJC_US), the cert included in your request p12.

The JWE protected header:

  • alg is RSA-OAEP-256, which wraps a random content-encryption key to Cybersource’s RSA public key.
  • enc is A256GCM, which encrypts the payload itself with AES-256-GCM.
  • kid is the serialNumber of the Cybersource certificate you encrypted to.

The encrypted body goes on the wire as a small JSON envelope:

{ "encryptedRequest": "<compact-jwe>" }

The digest from Piece 1 covers the exact bytes you send. Once request MLE is on, those bytes are the {"encryptedRequest":…} envelope rather than the plaintext, so you encrypt first and then compute the digest over the envelope. The Digest Calculator tab compares an envelope against a JWT’s digest claim.

Piece 3: Response MLE (decrypting what comes back)

This is the response p12’s job. To get an encrypted response, you tell Cybersource which of your keys to encrypt it to by adding one more claim to the JWT from Piece 1:

  • v-c-response-mle-kid, the serialNumber of your response certificate.

Cybersource encrypts the response to that public key and returns its own envelope:

{ "encryptedResponse": "<compact-jwe>" }

You decrypt it with the private key from your response p12. That’s the whole reason the response p12 is a separate file: its private key never leaves your side, and it’s the only thing that can read the reply.

Standard key vs. meta key

The two key types differ only in identity. The claim set, the digest, and MLE are otherwise identical:

  • Standard key: iss and the signing certificate’s common name are your merchant ID.
  • Meta key: a portfolio-level key that can act for many merchants. iss and the common name are the portfolio ID, and you still pass the specific merchant in v-c-merchant-id on each request.

See it all together

Here’s a single POST /tms/v2/tokenize with both MLE directions on, traced end to end. This is the order the client library runs it, and exactly what the Live Test tab shows you:

  1. Start with your plaintext body. The tokenize request as ordinary JSON.
  2. Encrypt it (Piece 2). Wrap the plaintext in a JWE to Cybersource’s CyberSource_SJC_US cert; the wire body becomes {"encryptedRequest":"<jwe>"}.
  3. Digest the envelope. SHA-256 the encrypted bytes from the previous step (not the plaintext) and Base64-encode it.
  4. Build and sign the JWT (Piece 1). Assemble the claims (host, path, lowercase method, iss/v-c-merchant-id, iat/exp, the digest from step 3, and v-c-response-mle-kid naming your response cert), then sign RS256 with the request p12’s key.
  5. Send it. Authorization: Bearer <jwt>, body = the {"encryptedRequest":…} envelope.
  6. Decrypt the response (Piece 3). Cybersource returns {"encryptedResponse":"<jwe>"}; decrypt it with your response p12 key to get the plaintext result.

Each step produces an artifact, and a failure usually points at one of them: a 401 is the JWT (clock skew, wrong kid, wrong key); a digest mismatch is step 3 (hashing the wrong bytes, usually the plaintext instead of the envelope); “unable to decrypt request” is step 2 (encrypted to the wrong cert); a garbled response is step 6 (wrong response key). The Live Test tab lays all six steps out for a real sandbox call.

The library

The @ryankleindev/cybs-jwt-client library implements all of this and returns a full trace of every artifact: the plaintext body, the signed JWT, the JWE headers, the digest, and the raw and decrypted response.

npm install @ryankleindev/cybs-jwt-client

The full source and usage docs are on GitHub.

Try it

The tabs above are working tools:

  • p12 Explorer: see what is in your keystore and which value is your kid.
  • JWT / JWE Decoder: break down a token and check every required claim.
  • Digest Calculator: confirm your digest matches the bytes you are sending.
  • Live Test: run an end-to-end sandbox tokenize and inspect the trace.

Everything runs against the Cybersource sandbox, and nothing you enter is stored or logged.

Sandbox keys only. Your .p12 and password are sent to the function, used in memory, and never stored or logged. Do not upload production keys.

Open a PKCS#12 (.p12) keystore and list its certificates: common name, the serialNumber that becomes the JWT kid, validity dates, and which cert is yours vs. Cybersource’s MLE cert.

For inspection only. The signature isn’t verified, and nothing is stored or logged. Avoid pasting production tokens you consider sensitive.

Paste a compact JWT (3 segments) or JWE (5 segments). For a JWT, every Cybersource-required claim is checked. The signature is not verified; this is a decoder and validator.

For testing and learning. Nothing you enter is stored or logged.

Compute the SHA-256 base64 digest over the exact request body. This must equal the JWT digest claim. With request MLE on, hash the {"encryptedRequest":…} envelope (the bytes on the wire), not the plaintext.

Sandbox credentials only. Calls the Cybersource sandbox (apitest.cybersource.com) only. Your p12s and passwords are used in memory and never stored or logged. Do not use production keys or live card data.

Run a real sandbox tokenize call (POST /tms/v2/tokenize, request & response MLE on) using your sandbox keys, and see the full trace: the signed JWT, the request JWE header, the digest, request headers, and the raw vs. decrypted response. Powered by the open-source client library.