# Cybersource JWT Authentication with Message-Level Encryption (MLE)

https://ryanklein.dev/blog/cybersource-jwt-authentication-with-mle
Published 2026-06-14 · Updated 2026-10-25

The facts you need to get Cybersource REST authentication and message-level encryption right, for certificate or shared-secret signing, laid out field by field with working code and tools to inspect every artifact.

> **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

**If you sign with a shared secret:**

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

**If you sign with a certificate (p12):**

| 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>"}`.

<div class="tip">
<p class="tip-label">Check MLE support first</p>

MLE is rolling out endpoint by endpoint, so not every endpoint supports it yet. Check Cybersource's [list of MLE-supported endpoints](https://developer.cybersource.com/products/authentication.html) 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`](https://github.com/panva/jose) for the JWT and JWE, and [`node-forge`](https://github.com/digitalbazaar/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.

**If you sign with a shared secret:**

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`:

```js
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]
}
```

**If you sign with a certificate (p12):**

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`:

```js
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

**If you sign with a shared secret:**

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

**If you sign with a certificate (p12):**

| 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](https://www.npmjs.com/package/@ryankleindev/cybs-jwt-client) library does every step above, including both directions of MLE, and returns a `trace` of each artifact:

**If you sign with a shared secret:**

```js
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
```

**If you sign with a certificate (p12):**

```js
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](https://github.com/ryankleindev/cybs-jwt-client).
