Encrypting and decrypting payloads

Every funding request and response is an encrypted JWE. The plaintext inside is a JWT carrying the business claims.

The wire format is a compact-serialized JWE (RFC 7516 §7.1): five base64url parts separated by dots, as protected_header.encrypted_key.iv.ciphertext.tag.

The parameters #

Both directions use the same cryptography:

ParameterValue
Key management (alg)A256KW (AES-256 key wrap)
Content encryption (enc)A256GCM
Header typ / ctyJWT / JWT (the content is a nested JWT)
Wrapping keyThe raw 32-byte signing secret from setup

The inner JWT has standard iat and exp claims (Unix seconds) at the top level. All Moov fields live under a single moov object, so they cannot collide with registered JWT claims (sub, aud, and the rest):

{
  "iat": 1710000000,
  "exp": 1710000300,
  "moov": { "idempotencyKey": "…", "payoutID": "…", "amount": { } }
}

Field-by-field in the API reference (authorize, credit account). Moov Money issues requests with exp = iat + 5 minutes; reject requests where exp has passed or iat is implausibly far in the future, allowing a small clock-skew leeway.

Decrypt a request #

Your endpoint receives { "request": "<JWE>" }. Unwrap it with your 32-byte secret:

Using the jose package, jwtDecrypt decrypts the JWE and validates iat/exp in one call:

import { jwtDecrypt, base64url } from 'jose';

// The secret exactly as returned by POST /providers/{id}/signing-secrets
const key = base64url.decode(process.env.MOOV_SIGNING_SECRET);

export async function readRequest(body) {
  const { payload } = await jwtDecrypt(body.request, key, {
    contentEncryptionAlgorithms: ['A256GCM'],
    keyManagementAlgorithms: ['A256KW'],
    clockTolerance: '30s',
  });
  // payload.moov.idempotencyKey, payload.moov.payoutID, payload.moov.amount
  return payload;
}

Encrypt a response #

Build the moov object, always echoing the request’s idempotencyKey, and encrypt it with the same key. iat and exp stay at the top of the JWT, not inside moov:

import { EncryptJWT, base64url } from 'jose';

const key = base64url.decode(process.env.MOOV_SIGNING_SECRET);

export async function writeResponse(moov) {
  const jweString = await new EncryptJWT({ moov })
    .setProtectedHeader({ alg: 'A256KW', enc: 'A256GCM', typ: 'JWT', cty: 'JWT' })
    .setIssuedAt()
    .setExpirationTime('5m')
    .encrypt(key);

  return { response: jweString };
}

// e.g. approving an authorize request:
await writeResponse({
  idempotencyKey: request.moov.idempotencyKey, // echoed; Moov rejects a mismatch
  outcome: 'approved',
  holdReference: 'hold_9f8e7d6c5b4a',
});

Verifying your implementation #

Round-trip your own output before pointing Moov Money at it: encrypt a response, decrypt it with the same key, and confirm the claims survive intact. The most common integration failures are using the base64url string as the key instead of the decoded 32 bytes, and omitting the cty: JWT header parameter.

Next steps #