Payload Encryption
Pinwheel supports Payload Encryption, sometimes referred to as Message-Level Encryption (MLE), for sensitive fields when creating Link Tokens. Payload Encryption adds application-layer encryption on top of HTTPS/TLS by encrypting selected request fields before they are sent to Pinwheel.
Payload Encryption uses an envelope encryption pattern:
- Your application serializes the supported sensitive Link Token fields as JSON.
- Your application encrypts that JSON with a fresh, one-time AES key.
- Your application encrypts the AES key with the Pinwheel public encryption key.
- Your application sends the encrypted JSON and encryption metadata to Pinwheel.
Payloads encrypted with the Pinwheel public encryption key can only be decrypted with the corresponding private key controlled by Pinwheel.
Looking for code? A complete Python example is included near the bottom of this guide.
Security overview
Payload Encryption is designed to protect sensitive request fields at the application layer, in addition to HTTPS/TLS transport security.
Key points:
- Application-layer protection: Sensitive fields are encrypted before the request leaves your application.
- Envelope encryption: The request JSON is encrypted with AES-GCM, while RSA-OAEP encrypts only the one-time AES key.
- Fresh key material per request: Each encrypted request uses a new random AES key and nonce.
- Mode-specific keys: Each Pinwheel API mode uses separate encryption material.
- Zero-downtime rotation:
key_pair_idlets Pinwheel identify which public encryption key was used for a request, enabling key rotation without interrupting traffic.
Requirements
Before sending encrypted Link Token requests, Pinwheel will provide the following through a secure channel:
- Pinwheel public encryption key: Mode-specific RSA-2048 public key in PEM format, commonly with a
-----BEGIN PUBLIC KEY-----header. key_pair_id: UUID identifying the public encryption key. Include this value with every encrypted request.
Use the public encryption key and key_pair_id for the same API mode as the request you are making. For example, use sandbox encryption material with sandbox Link Token requests, and production encryption material with production requests.
If Pinwheel provides a new public encryption key during rotation, begin encrypting new requests with the new key and include its corresponding key_pair_id.
Supported encrypted fields
For Create Link Token requests, encrypted_json replaces supported top-level request fields.
| Field | Description |
|---|---|
end_user | End-user matching and identity fields. |
allocation | Allocation and target bank account details. |
cards | Card details, when applicable. |
You can encrypt any subset of these supported top-level objects. Pinwheel recommends encrypting all supported fields that contain sensitive data for the request.
If you encrypt end_user, allocation, or cards, include the complete object exactly as it would appear in the unencrypted request. Do not encrypt only individual nested fields.
Keep non-sensitive request routing and configuration fields, such as solution, features, org_name, and end_user_id, at the top level of the request.
Encryption algorithm
Use the following algorithm requirements for each encrypted request:
| Component | Requirement |
|---|---|
| JSON serialization | Serialize the sensitive-fields object to a UTF-8 JSON string. The decrypted result must parse as JSON and match the expected Link Token schema. |
| AES algorithm | AES-256-GCM. |
| AES request key | 32 random bytes. Generate a new key for every request. |
| Nonce | 12 random bytes. Generate a new nonce for every request. |
| RSA algorithm | RSA-OAEP using the Pinwheel public encryption key. |
| OAEP hash | SHA-512. |
| Encoding | Standard Base64 with padding. Do not use Base64URL. |
encrypted_json should be the Base64 encoding of the AES-GCM ciphertext bytes only. Do not append an AES-GCM authentication tag unless a Pinwheel endpoint explicitly documents tag support.
Request format
An encrypted Create Link Token request includes these fields:
| Field | Type | Description |
|---|---|---|
encrypted_json | string | Standard Base64-encoded AES-GCM ciphertext of the sensitive-fields JSON object. |
encryption_envelope.key_pair_id | string UUID | ID for the Pinwheel public encryption key used for the request. |
encryption_envelope.encrypted_request_key | string | Standard Base64-encoded RSA-OAEP ciphertext of the one-time AES request key. |
encryption_envelope.request_nonce | string | Standard Base64-encoded 12-byte AES-GCM nonce. |
Example: transforming an unencrypted request body
1. Start with the unencrypted request body
This example shows a Link Token request before you apply Payload Encryption.
{
"solution": "Deposit Switch",
"features": ["direct_deposit_switch"],
"org_name": "Example App",
"end_user_id": "my_user_12345",
"end_user": {
"platform_matching": {
"social_security_number": "123456789",
"mobile_phone_number": "1234567890",
"home_address_zip_code": "12345",
"first_name": "Alicia",
"last_name": "Green",
"date_of_birth": "1990-01-31"
}
},
"allocation": {
"type": "amount",
"value": 123.45,
"targets": [
{
"name": "My Checking Account",
"type": "checking",
"account_number": "5675249802",
"routing_number": "193487629"
}
]
}
}2. Sensitive fields to encrypt
Move the supported sensitive top-level fields into one JSON object before encryption:
{
"end_user": {
"platform_matching": {
"social_security_number": "123456789",
"mobile_phone_number": "1234567890",
"home_address_zip_code": "12345",
"first_name": "Alicia",
"last_name": "Green",
"date_of_birth": "1990-01-31"
}
},
"allocation": {
"type": "amount",
"value": 123.45,
"targets": [
{
"name": "My Checking Account",
"type": "checking",
"account_number": "5675249802",
"routing_number": "193487629"
}
]
}
}3. Encrypted request body
After encryption, remove the sensitive top-level fields from the request and replace them with encrypted_json and encryption_envelope.
{
"solution": "Deposit Switch",
"features": ["direct_deposit_switch"],
"org_name": "Example App",
"end_user_id": "my_user_12345",
"encrypted_json": "qI4sNf2eWSt4dHnB5w0R...",
"encryption_envelope": {
"key_pair_id": "00000000-0000-0000-0000-000000000000",
"encrypted_request_key": "hQG2Vb4H2Vy7vY8YDz1Q...",
"request_nonce": "8Gv4Zd2zqkxZpB1Y"
}
}Example: send an encrypted create link token request with Python
Install dependencies:
pip install pycryptodome requestsExample implementation:
import json
import os
import uuid
from base64 import b64encode
from pathlib import Path
import requests
from Crypto.Cipher import AES, PKCS1_OAEP
from Crypto.Hash import SHA512
from Crypto.PublicKey.RSA import import_key
from Crypto.Random import get_random_bytes
PINWHEEL_LINK_TOKEN_URL = "https://<pinwheel-api-host>/v1/link_tokens"
PINWHEEL_VERSION = "2025-07-08"
# Use the API secret, key pair ID, and public encryption key for the same Pinwheel API mode.
API_SECRET = os.environ["PINWHEEL_API_SECRET"]
KEY_PAIR_ID = os.environ["PINWHEEL_KEY_PAIR_ID"]
PUBLIC_KEY_PATH = Path(__file__).parent / "pinwheel_public_encryption_key.pem"
# Include any supported top-level objects you want to encrypt in the encrypted payload.
# For Create Link Token, supported encrypted fields are: end_user, allocation, and cards.
encryption_target = {
"end_user": {
"platform_matching": {
"social_security_number": "123456789",
"mobile_phone_number": "1234567890",
"home_address_zip_code": "12345",
"first_name": "Alicia",
"last_name": "Green",
"date_of_birth": "1990-01-31",
}
},
"allocation": {
"type": "amount",
"value": 123.45,
"targets": [
{
"name": "My Checking Account",
"type": "checking",
"account_number": "5675249802",
"routing_number": "193487629",
}
],
},
}
with PUBLIC_KEY_PATH.open("r") as key_file:
public_key = import_key(key_file.read())
# 1. Generate a fresh AES-GCM key and nonce for this request.
request_key = get_random_bytes(32) # 256-bit AES key
request_nonce = get_random_bytes(12) # 96-bit AES-GCM nonce
# 2. Serialize and encrypt the supported sensitive fields with AES-GCM.
formatted_payload = json.dumps(encryption_target, separators=(",", ":"))
aes_cipher = AES.new(request_key, AES.MODE_GCM, nonce=request_nonce)
encrypted_json = aes_cipher.encrypt(formatted_payload.encode("utf-8"))
# 3. Encrypt only the one-time AES key with Pinwheel's public encryption key.
rsa_cipher = PKCS1_OAEP.new(public_key, hashAlgo=SHA512)
encrypted_request_key = rsa_cipher.encrypt(request_key)
# 4. Base64 encode the encrypted values and nonce for JSON transport.
encrypted_json_b64 = b64encode(encrypted_json).decode("ascii")
encrypted_request_key_b64 = b64encode(encrypted_request_key).decode("ascii")
request_nonce_b64 = b64encode(request_nonce).decode("ascii")
# 5. Send the Link Token request. Keep non-sensitive fields at the top level.
response = requests.post(
PINWHEEL_LINK_TOKEN_URL,
headers={
"Pinwheel-Version": PINWHEEL_VERSION,
"X-API-SECRET": API_SECRET,
},
json={
"solution": "Deposit Switch",
"features": ["direct_deposit_switch"],
"org_name": "Example App",
"end_user_id": str(uuid.uuid4()),
"encrypted_json": encrypted_json_b64,
"encryption_envelope": {
"key_pair_id": KEY_PAIR_ID,
"encrypted_request_key": encrypted_request_key_b64,
"request_nonce": request_nonce_b64,
},
},
)
response.raise_for_status()
print(json.dumps(response.json(), indent=2))
Security best practices
| Practice | Recommendation |
|---|---|
| Generate new values per request | Generate a new AES request key and nonce for every encrypted request. |
| Never reuse key/nonce pairs | Do not reuse the same AES key and nonce combination across requests. |
| Protect configuration | Store the Pinwheel public encryption key and key_pair_id in your normal application configuration or secret-management system. |
| Limit logging | Do not log plaintext sensitive data, AES request keys, nonces, encrypted request keys, or full encrypted payloads. |
| Match API modes | Keep sandbox and production encryption material separate and use the correct values for each request. |
Updated about 3 hours ago