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:
- npm package: @ryankleindev/cybs-jwt-client
- Source: github.com/ryankleindev/cybs-jwt-client
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
serialNumberbecomes the JWT’skid. 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:
- Authenticate every call with a signed JWT, the baseline required on every request.
- Encrypt the request (request MLE), using Cybersource’s cert from the request p12.
- 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:
algisRS256.kidis theserialNumberattribute 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:
expis two minutes afteriat, so a token is valid for two minutes. Clock skew beyond that fails authentication, a common cause of intermittent401s.request-methodis lowercase, andrequest-resource-pathis the path only (no host, no query string).issandv-c-merchant-idare 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.
digestanddigest-algorithmappear 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:
algisRSA-OAEP-256, which wraps a random content-encryption key to Cybersource’s RSA public key.encisA256GCM, which encrypts the payload itself with AES-256-GCM.kidis theserialNumberof 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, theserialNumberof 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:
issand the signing certificate’s common name are your merchant ID. - Meta key: a portfolio-level key that can act for many merchants.
issand the common name are the portfolio ID, and you still pass the specific merchant inv-c-merchant-idon 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:
- Start with your plaintext body. The tokenize request as ordinary JSON.
- Encrypt it (Piece 2). Wrap the plaintext in a JWE to Cybersource’s
CyberSource_SJC_UScert; the wire body becomes{"encryptedRequest":"<jwe>"}. - Digest the envelope. SHA-256 the encrypted bytes from the previous step (not the plaintext) and Base64-encode it.
- Build and sign the JWT (Piece 1). Assemble the claims (host, path, lowercase method,
iss/v-c-merchant-id,iat/exp, thedigestfrom step 3, andv-c-response-mle-kidnaming your response cert), then sign RS256 with the request p12’s key. - Send it.
Authorization: Bearer <jwt>, body = the{"encryptedRequest":…}envelope. - 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
digestmatches 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.
.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.
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.
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.
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.