Cybersource JWT Authentication with Message-Level Encryption (MLE)
TL;DR: Every Cybersource REST call carries a signed JWT, and message-level encryption (MLE) wraps the request and response bodies in JWEs. This page lists every value that has to be right, with working code and tools to help you troubleshoot issues as they come up.
Your keys
| Key | Where it comes from | Used for |
|---|---|---|
| Shared secret (key ID + secret) | Business Center, as REST – Shared Secret | signing the JWT |
CyberSource_SJC_US |
response p12 | encrypting requests |
| Response key and certificate | response p12 | decrypting responses |
| Key | Where it comes from | Used for |
|---|---|---|
| Signing key and certificate | request p12 | signing the JWT |
CyberSource_SJC_US |
every p12 Cybersource issues | encrypting requests |
| Response key and certificate | response p12 | decrypting responses |
You create keys in Business Center under Payment Configuration > Key Management. The p12 Explorer tab shows what’s inside a p12 and which value is each kid.
The flow
- Serialize your JSON body.
- Encrypt it to
CyberSource_SJC_USand wrap it:{"encryptedRequest": "<jwe>"}. - Hash that envelope for the
digestclaim. - Build the claims and sign the JWT.
- Send it with
Authorization: Bearer <jwt>. - Decrypt the response:
{"encryptedResponse": "<jwe>"}.
Check MLE support first
MLE is rolling out endpoint by endpoint, so not every endpoint supports it yet. Check Cybersource’s list of MLE-supported endpoints before you build it. Without MLE, skip steps 2 and 6 and hash the plain body.
The code
Here’s the whole flow in Node 18+. The six steps run in order at the top, and each calls a short function defined below. It uses two common libraries: jose for the JWT and JWE, and node-forge to read the p12 files, which Node can’t do on its own (npm install jose node-forge). It tokenizes a test card in the sandbox with MLE in both directions.
Put response.p12 next to the script, set CYBS_MERCHANT_ID, CYBS_SHARED_SECRET_KEY_ID, CYBS_SHARED_SECRET (the base64 value from Business Center), and CYBS_RESPONSE_P12_PASSWORD, save it as tokenize.mjs, and run node tokenize.mjs:
import crypto from 'node:crypto'
import fs from 'node:fs'
import forge from 'node-forge'
import { CompactEncrypt, SignJWT, compactDecrypt } from 'jose'
const host = 'apitest.cybersource.com'
const path = '/tms/v2/tokenize'
const merchantId = process.env.CYBS_MERCHANT_ID
const responseP12 = loadP12('response.p12', process.env.CYBS_RESPONSE_P12_PASSWORD)
// Step 1: serialize the body
const plaintext = JSON.stringify({
processingInformation: { actionList: ['TOKEN_CREATE'], actionTokenTypes: ['instrumentIdentifier'] },
tokenInformation: { instrumentIdentifier: { card: { number: '4622943123116478' } } }
})
// Step 2: encrypt it to CyberSource_SJC_US and wrap it
const body = JSON.stringify({ encryptedRequest: await encryptRequest(plaintext, responseP12.cybersourceCert) })
// Step 3: digest the exact bytes you'll send
const digest = crypto.createHash('sha256').update(body).digest('base64')
// Step 4: build the claims and sign the JWT
const jwt = await signJwt(buildClaims(digest, responseP12.yourCert))
// Step 5: send it
const encryptedResponse = await send(jwt, body)
// Step 6: decrypt the response
const { plaintext: result } = await compactDecrypt(encryptedResponse, responseP12.key)
console.log(new TextDecoder().decode(result))
// ---- Helpers, in step order ----
// Step 2: a JWE to Cybersource's certificate
function encryptRequest (plaintext, cert) {
return new CompactEncrypt(new TextEncoder().encode(plaintext))
.setProtectedHeader({
alg: 'RSA-OAEP-256',
enc: 'A256GCM',
cty: 'JWT',
kid: serialNumberOf(cert),
iat: Math.floor(Date.now() / 1000)
})
.encrypt(cert.publicKey)
}
// Step 4: the claim set
function buildClaims (digest, responseCert) {
const iat = Math.floor(Date.now() / 1000) - 5 // a few seconds back absorbs clock drift
return {
iat,
exp: iat + 120,
'request-host': host,
'request-resource-path': path,
'request-method': 'post',
iss: merchantId, // portfolio ID for a meta key
jti: crypto.randomUUID(),
'v-c-jwt-version': '2',
'v-c-merchant-id': merchantId,
digest,
'digest-algorithm': 'SHA-256',
'v-c-response-mle-kid': serialNumberOf(responseCert)
}
}
// Step 4: sign with the shared secret (HS256)
function signJwt (claims) {
const secret = Buffer.from(process.env.CYBS_SHARED_SECRET, 'base64') // decode the base64 secret first
return new SignJWT(claims)
.setProtectedHeader({ typ: 'JWT', alg: 'HS256', kid: process.env.CYBS_SHARED_SECRET_KEY_ID })
.sign(secret)
}
// Step 5: POST with the JWT as the only auth header
async function send (jwt, body) {
const res = await fetch(`https://${host}${path}`, {
method: 'POST',
headers: { Authorization: `Bearer ${jwt}`, 'Content-Type': 'application/json' },
body
})
const text = await res.text()
const { encryptedResponse } = text.startsWith('{') ? JSON.parse(text) : {}
if (!encryptedResponse) throw new Error(`HTTP ${res.status}: ${text}`)
return encryptedResponse
}
// Reads a Cybersource p12: your private key, your certificate, and CyberSource_SJC_US
function loadP12 (file, password) {
const p12 = forge.pkcs12.pkcs12FromAsn1(forge.asn1.fromDer(fs.readFileSync(file, 'binary')), password)
const bags = (type) => p12.getBags({ bagType: type })[type]
const certs = bags(forge.pki.oids.certBag).map((b) => new crypto.X509Certificate(forge.pki.certificateToPem(b.cert)))
return {
key: crypto.createPrivateKey(forge.pki.privateKeyToPem(bags(forge.pki.oids.pkcs8ShroudedKeyBag)[0].key)),
yourCert: certs.find((c) => !c.subject.includes('CN=CyberSource')),
cybersourceCert: certs.find((c) => c.subject.includes('CN=CyberSource_SJC_US'))
}
}
// A certificate's subject serialNumber, which Cybersource uses as its kid
function serialNumberOf (cert) {
return cert.subject.match(/serialNumber=(\w+)/)[1]
}
Put request.p12 and response.p12 next to the script, set CYBS_MERCHANT_ID, CYBS_REQUEST_P12_PASSWORD, and CYBS_RESPONSE_P12_PASSWORD, save it as tokenize.mjs, and run node tokenize.mjs:
import crypto from 'node:crypto'
import fs from 'node:fs'
import forge from 'node-forge'
import { CompactEncrypt, SignJWT, compactDecrypt } from 'jose'
const host = 'apitest.cybersource.com'
const path = '/tms/v2/tokenize'
const merchantId = process.env.CYBS_MERCHANT_ID
const requestP12 = loadP12('request.p12', process.env.CYBS_REQUEST_P12_PASSWORD)
const responseP12 = loadP12('response.p12', process.env.CYBS_RESPONSE_P12_PASSWORD)
// Step 1: serialize the body
const plaintext = JSON.stringify({
processingInformation: { actionList: ['TOKEN_CREATE'], actionTokenTypes: ['instrumentIdentifier'] },
tokenInformation: { instrumentIdentifier: { card: { number: '4622943123116478' } } }
})
// Step 2: encrypt it to CyberSource_SJC_US and wrap it
const body = JSON.stringify({ encryptedRequest: await encryptRequest(plaintext, responseP12.cybersourceCert) })
// Step 3: digest the exact bytes you'll send
const digest = crypto.createHash('sha256').update(body).digest('base64')
// Step 4: build the claims and sign the JWT
const jwt = await signJwt(buildClaims(digest, responseP12.yourCert))
// Step 5: send it
const encryptedResponse = await send(jwt, body)
// Step 6: decrypt the response
const { plaintext: result } = await compactDecrypt(encryptedResponse, responseP12.key)
console.log(new TextDecoder().decode(result))
// ---- Helpers, in step order ----
// Step 2: a JWE to Cybersource's certificate
function encryptRequest (plaintext, cert) {
return new CompactEncrypt(new TextEncoder().encode(plaintext))
.setProtectedHeader({
alg: 'RSA-OAEP-256',
enc: 'A256GCM',
cty: 'JWT',
kid: serialNumberOf(cert),
iat: Math.floor(Date.now() / 1000)
})
.encrypt(cert.publicKey)
}
// Step 4: the claim set
function buildClaims (digest, responseCert) {
const iat = Math.floor(Date.now() / 1000) - 5 // a few seconds back absorbs clock drift
return {
iat,
exp: iat + 120,
'request-host': host,
'request-resource-path': path,
'request-method': 'post',
iss: merchantId, // portfolio ID for a meta key
jti: crypto.randomUUID(),
'v-c-jwt-version': '2',
'v-c-merchant-id': merchantId,
digest,
'digest-algorithm': 'SHA-256',
'v-c-response-mle-kid': serialNumberOf(responseCert)
}
}
// Step 4: sign with your request p12's private key (RS256)
function signJwt (claims) {
return new SignJWT(claims)
.setProtectedHeader({ typ: 'JWT', alg: 'RS256', kid: serialNumberOf(requestP12.yourCert) })
.sign(requestP12.key)
}
// Step 5: POST with the JWT as the only auth header
async function send (jwt, body) {
const res = await fetch(`https://${host}${path}`, {
method: 'POST',
headers: { Authorization: `Bearer ${jwt}`, 'Content-Type': 'application/json' },
body
})
const text = await res.text()
const { encryptedResponse } = text.startsWith('{') ? JSON.parse(text) : {}
if (!encryptedResponse) throw new Error(`HTTP ${res.status}: ${text}`)
return encryptedResponse
}
// Reads a Cybersource p12: your private key, your certificate, and CyberSource_SJC_US
function loadP12 (file, password) {
const p12 = forge.pkcs12.pkcs12FromAsn1(forge.asn1.fromDer(fs.readFileSync(file, 'binary')), password)
const bags = (type) => p12.getBags({ bagType: type })[type]
const certs = bags(forge.pki.oids.certBag).map((b) => new crypto.X509Certificate(forge.pki.certificateToPem(b.cert)))
return {
key: crypto.createPrivateKey(forge.pki.privateKeyToPem(bags(forge.pki.oids.pkcs8ShroudedKeyBag)[0].key)),
yourCert: certs.find((c) => !c.subject.includes('CN=CyberSource')),
cybersourceCert: certs.find((c) => c.subject.includes('CN=CyberSource_SJC_US'))
}
}
// A certificate's subject serialNumber, which Cybersource uses as its kid
function serialNumberOf (cert) {
return cert.subject.match(/serialNumber=(\w+)/)[1]
}
Printing a decrypted responses list means every step worked. The values each step has to get right are below.
The values, step by step
Step 2: Encrypt the request
| JWE header field | Value |
|---|---|
alg |
RSA-OAEP-256 |
enc |
A256GCM |
kid |
the serialNumber of CyberSource_SJC_US |
The body you send is {"encryptedRequest": "<jwe>"}.
Step 3: Digest the envelope
| Claim | Value |
|---|---|
digest |
base64 SHA-256 of the exact body bytes you send: the envelope with MLE, the JSON without |
digest-algorithm |
"SHA-256" |
POST, PUT, and PATCH carry both claims; GET and DELETE omit them.
Step 4: Sign the JWT
| JWT header field | Value |
|---|---|
alg |
HS256 (or HS384, HS512) |
kid |
your shared secret’s key ID |
typ |
JWT |
| Signature | HMAC keyed with the base64-decoded secret. Using the base64 text as the key fails with a 401. |
| JWT header field | Value |
|---|---|
alg |
RS256 |
kid |
the signing certificate’s subject serialNumber (not the X.509 serial number, and not your merchant ID) |
typ |
JWT |
| Signature | RSA-SHA256 with the request p12’s private key |
| Claim | Value |
|---|---|
iat |
issue time in epoch seconds; set it a few seconds in the past to absorb clock drift |
exp |
iat + 120 |
iss |
your merchant ID, or your portfolio ID with a meta key |
v-c-merchant-id |
your merchant ID, always |
request-host |
the host only, e.g. apitest.cybersource.com |
request-resource-path |
the path only, no query string, e.g. /tms/v2/tokenize |
request-method |
lowercase, e.g. post |
jti |
a new UUID per request |
v-c-jwt-version |
"2" |
digest, digest-algorithm |
from step 3 |
v-c-response-mle-kid |
your response certificate’s serialNumber (response MLE only) |
Step 5: Send it
| HTTP header | Value |
|---|---|
Authorization |
Bearer <jwt>, the only auth header |
Content-Type |
application/json |
Step 6: Decrypt the response
| Part | Value |
|---|---|
| Response body | {"encryptedResponse": "<jwe>"} |
| Decrypt with | the response p12’s private key (the one v-c-response-mle-kid named in step 4) |
Or use the library
The @ryankleindev/cybs-jwt-client library does every step above, including both directions of MLE, and returns a trace of each artifact:
const { CybsJwtClient } = require('@ryankleindev/cybs-jwt-client')
const client = new CybsJwtClient({
runEnvironment: 'apitest.cybersource.com',
merchantId: 'your_merchant_id',
sharedSecret: { keyId: process.env.SHARED_SECRET_KEY_ID, secret: process.env.SHARED_SECRET },
responseP12: { path: 'response.p12', password: process.env.RESPONSE_P12_PASSWORD }
})
const res = await client.post('/tms/v2/tokenize', body, { mle: { request: true, response: true } })
console.log(res.data) // the decrypted response
console.log(res.trace) // the JWT, digest, JWE headers, and raw response
const { CybsJwtClient } = require('@ryankleindev/cybs-jwt-client')
const client = new CybsJwtClient({
runEnvironment: 'apitest.cybersource.com',
merchantId: 'your_merchant_id',
requestP12: { path: 'request.p12', password: process.env.REQUEST_P12_PASSWORD },
responseP12: { path: 'response.p12', password: process.env.RESPONSE_P12_PASSWORD }
})
const res = await client.post('/tms/v2/tokenize', body, { mle: { request: true, response: true } })
console.log(res.data) // the decrypted response
console.log(res.trace) // the JWT, digest, JWE headers, and raw response
Common questions
Why do I get intermittent 401s?
Clock drift. Tokens are valid for two minutes, so set iat a few seconds in the past and keep your host clock synced.
What does a 401 tell me?
Only that authentication failed, so nothing reached the API. Check the step 4 values above, starting with kid and the signing key or secret.
Why are some responses encrypted and others not?
MLE support varies by endpoint. Check Cybersource’s MLE-supported endpoints list, and confirm your JWT includes v-c-response-mle-kid.
My encrypted response contains an error. Now what?
Authentication and MLE worked. The error comes from the API itself, so decrypt it and troubleshoot the request.
Can I sign with a shared secret instead of a certificate?
Yes. Cybersource accepts an HMAC-signed JWT (HS256, HS384, or HS512) using a shared secret from Business Center. Everything else, including MLE, stays the same; the tabs on this page show the differences.
Tools
The tabs above check your own artifacts against these values: the p12 Explorer finds your kid values, the JWT / JWE Decoder checks every claim, the Digest Calculator confirms your digest, and the Live Test runs a known-good sandbox call and shows its full trace. Everything runs against the Cybersource sandbox, and nothing you enter is stored or logged. The client’s source is on GitHub.
.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, passwords, and shared secret are used
in memory and never stored or logged. Do not use production keys or live card data.
Run a real sandbox call with your keys and see every artifact: the signed JWT, the encrypted request, the digest, and the raw and decrypted response.