Cybersource JWT Authentication with Message-Level Encryption (MLE)
TL;DR: Cybersource REST calls use a per-request JWT for authentication and message-level encryption (MLE) for the payload. You work with two p12 files: one signs your requests and encrypts what you send, the other decrypts what comes back. This guide steps through all three pieces (authentication, request MLE, and response MLE) with interactive tools to inspect each artifact.
Cybersource REST API requests are signed with a per-request JWT, and the payloads can 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. One signs your requests and encrypts what you send; the other decrypts what comes back. (Cybersource also lets you sign the JWT with a shared secret key instead of a certificate; this guide uses the certificate path.) 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.
CyberSource_SJC_US is Cybersource’s own public certificate, the same for every merchant, and Cybersource bundles a copy into every .p12 it issues you, so it appears in both files; this guide uses the copy in the request p12. You’ll also see a CA (issuer) certificate in each bundle, which is 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 being enabled across the REST API and is set to become required; for now it’s supported on a subset of endpoints, which Cybersource is expanding (it maintains the current list of MLE-supported endpoints). Where an endpoint supports it, you can apply MLE to the request, the response, or both, and this guide covers all three pieces.
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 same three pieces apply to any Cybersource REST endpoint; only the path and body change. Authorizing an Apple Pay payment, for instance, is this same flow against the payments API.
Common questions
A few issues come up often, and each one points at a specific piece of the flow above.
Why am I getting intermittent failures?
This is usually clock drift between your host and Cybersource. The JWT is valid for only two minutes (exp is two minutes after iat), so even small differences between your clock and the server’s can push a token outside its window some of the time. Set iat a few seconds in the past (3 to 5 seconds) to absorb the drift, and make sure your host’s clock is synced.
I’m getting a 401 / unauthorized response. What does that mean?
A 401 means the JWT itself is the problem, and the request never made it past authentication, so nothing reached the underlying API. Recheck the token: the kid (your signing certificate’s subject serialNumber), the signing key, and the claims (request-host, lowercase request-method, request-resource-path, and iat/exp). Paste the token into the JWT / JWE Decoder tab to confirm every required claim is present and correct.
Why are some responses encrypted and others returned in the clear?
MLE is being enabled across the REST API endpoint by endpoint, so support varies by endpoint right now. Check Cybersource’s MLE-supported endpoints list to see which endpoints currently return encrypted responses.
I get an encrypted response back, but it contains an error. Now what?
The good news is that authentication and encryption are working: you signed in, the request was accepted, and you decrypted the reply. The error is coming from the underlying product or service API, not from auth or MLE. Decrypt the response and read the error details to troubleshoot the API call itself.
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.