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

  1. Serialize your JSON body.
  2. Encrypt it to CyberSource_SJC_US and wrap it: {"encryptedRequest": "<jwe>"}.
  3. Hash that envelope for the digest claim.
  4. Build the claims and sign the JWT.
  5. Send it with Authorization: Bearer <jwt>.
  6. 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.

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, 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.

Account
Sign the JWT
Message-level encryption
Request MLE
Encrypts the body to CyberSource_SJC_US, which Cybersource bundles in this p12.
Response MLE
Decrypts the reply with this p12's private key.

Sends POST /tms/v2/tokenize to apitest.cybersource.com.