> ## Documentation Index
> Fetch the complete documentation index at: https://docs.gnosispay.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Handle sensitive card data

> Show card details, reveal a PIN, and set a PIN while keeping plaintext card data out of your backend.

Sensitive card data is a separately enabled flow. Use it only if your organization has been approved to handle card data directly.

You don't need to deploy a separate encryption service. Your UI generates and keeps the session key, and your existing backend only does three things: it authenticates the user, checks they may access the card, and proxies Partner API requests.

> [!WARNING]
>
> Never send the Partner API access token to the browser.
>
> This design keeps plaintext card data and PINs out of your backend, but your UI still handles them. Your application, hosting, monitoring, and operational processes must meet the requirements agreed for your card program.

## Architecture

The browser encrypts and decrypts. Your backend only forwards encrypted values and never sees plaintext.

> [tab] Read card details or PIN
>
> The browser creates a single-use session key and sends only its encrypted form through your backend.
>
> ```mermaid theme={null}
> sequenceDiagram
>     actor User
>     participant UI as Integrator UI
>     participant Backend as Integrator backend
>     participant API as Partner API
>
>     UI->>Backend: Request current encryption keys
>     Backend->>API: GET /pci/public-key
>     API-->>Backend: encryption_keys
>     Backend-->>UI: encryption_keys
>     Note over UI: Generate a fresh 32-byte AES key<br/>Wrap its Base64 value with RSA-OAEP-SHA256
>     User->>UI: Request card details or PIN
>     UI->>Backend: card_id, encrypted_key, key_id
>     Backend->>API: GET encrypted card data
>     API-->>Backend: AES-GCM ciphertext and IV
>     Backend-->>UI: Forward encrypted response unchanged
>     Note over UI: Decrypt in memory and display briefly
> ```

> [tab] Set a PIN
>
> The browser encrypts the PIN before it reaches your backend.
>
> ```mermaid theme={null}
> sequenceDiagram
>     actor User
>     participant UI as Integrator UI
>     participant Backend as Integrator backend
>     participant API as Partner API
>
>     UI->>Backend: Request current encryption keys
>     Backend->>API: GET /pci/public-key
>     API-->>Backend: encryption_keys
>     Backend-->>UI: encryption_keys
>     User->>UI: Enter and confirm new PIN
>     Note over UI: Encode PIN Block 2<br/>Generate fresh AES key and IV<br/>Encrypt with AES-256-GCM<br/>Wrap key with RSA-OAEP-SHA256
>     UI->>Backend: encrypted_key, encrypted_pin, iv, key_id
>     Backend->>API: POST /pci/cards/{card_id}/pin
>     API-->>Backend: Success
>     Backend-->>UI: Success
>     Note over UI: Clear PIN, key, and encrypted values
> ```

### Backend routes

The examples on this page use `/api/payment-cards/...` for routes on your own backend. Your backend maps each one to a Partner API route, without decrypting or changing the sensitive payload.

| UI request to your backend | Partner API request |
| - | - |
| Get encryption keys | `GET /pci/public-key` |
| Read encrypted card details | `GET /pci/cards/{card_id}/details` |
| Read the encrypted PIN | `GET /pci/cards/{card_id}/pin` |
| Set an encrypted PIN | `POST /pci/cards/{card_id}/pin` |

## Encryption contract

Use these values exactly.

| Value | Requirement |
| - | - |
| Session key | 32 cryptographically random bytes, used as an AES-256 key |
| Wrapped session key | Base64-encode the 32-byte session key, encode that Base64 string as UTF-8, then encrypt it with RSA-OAEP-SHA256 |
| RSA public key | Select an entry from `encryption_keys` where `algorithm` is `RSA-OAEP-SHA256`; import its Base64-encoded DER key as SPKI |
| `key_id` | Send the selected encryption key's `id` with every wrapped session key |
| Sensitive payloads | AES-256-GCM with a 128-bit authentication tag |
| IV for setting a PIN | 12 cryptographically random bytes, Base64-encoded |
| Binary values | Standard Base64 with padding, not Base64 URL encoding |
| Key lifetime | Generate a new session key for every read or write, and discard it immediately afterwards |

> [!NOTE]
>
> Fetch the current keys immediately before each sensitive operation. Select from `encryption_keys`. Don't build against the top-level `public_key` field, which exists only for compatibility.

### Browser helpers

These helpers use the Web Crypto API, which works only in a secure browser context over HTTPS. The examples in the sections that follow build on them.

```typescript TypeScript theme={null}
type PciEncryptionKey = {
  id: string;
  algorithm: string;
  public_key: string;
};

type PciPublicKeyResponse = {
  public_key: string;
  encryption_keys: PciEncryptionKey[];
};

type SessionContext = {
  aesKey: CryptoKey;
  encryptedKey: string;
  keyId: string;
};

const encoder = new TextEncoder();
const decoder = new TextDecoder();

function bytesToBase64(bytes: Uint8Array): string {
  return btoa(String.fromCharCode(...bytes));
}

function base64ToBytes(value: string): Uint8Array {
  return Uint8Array.from(atob(value), (character) => character.charCodeAt(0));
}

function selectEncryptionKey(
  material: PciPublicKeyResponse,
): PciEncryptionKey {
  const key = material.encryption_keys.find(
    (candidate) => candidate.algorithm === "RSA-OAEP-SHA256",
  );

  if (!key) {
    throw new Error("No supported encryption key is available");
  }
  return key;
}

async function createSession(
  material: PciPublicKeyResponse,
): Promise<SessionContext> {
  const selected = selectEncryptionKey(material);
  const sessionKeyBytes = crypto.getRandomValues(new Uint8Array(32));
  const sessionKeyBase64 = bytesToBase64(sessionKeyBytes);

  const aesKey = await crypto.subtle.importKey(
    "raw",
    sessionKeyBytes,
    "AES-GCM",
    false,
    ["encrypt", "decrypt"],
  );

  const rsaKey = await crypto.subtle.importKey(
    "spki",
    base64ToBytes(selected.public_key),
    { name: "RSA-OAEP", hash: "SHA-256" },
    false,
    ["encrypt"],
  );

  // The API unwraps a UTF-8 encoded Base64 string, not the raw AES bytes.
  const wrapped = await crypto.subtle.encrypt(
    { name: "RSA-OAEP" },
    rsaKey,
    encoder.encode(sessionKeyBase64),
  );

  sessionKeyBytes.fill(0);
  return {
    aesKey,
    encryptedKey: bytesToBase64(new Uint8Array(wrapped)),
    keyId: selected.id,
  };
}

async function decryptAesGcm(
  ciphertext: string,
  iv: string,
  aesKey: CryptoKey,
): Promise<string> {
  const plaintext = await crypto.subtle.decrypt(
    {
      name: "AES-GCM",
      iv: base64ToBytes(iv),
      tagLength: 128,
    },
    aesKey,
    base64ToBytes(ciphertext),
  );

  return decoder.decode(plaintext);
}
```

## Retrieve card details

**1. Get the current keys**

Ask your backend for the current response from `GET /pci/public-key`.

**2. Create a fresh session**

Call `createSession`. Keep `aesKey` only in browser memory.

**3. Request the encrypted details**

Send `encrypted_key` and `key_id` to your backend. Your backend URL-encodes them and calls `GET /pci/cards/{card_id}/details` with its Partner API token.

**4. Forward the response unchanged**

Your backend returns the encrypted response to the UI as-is.

**5. Decrypt in the browser**

Decrypt `card_number` and `cvv` with the returned `iv`. `expiration_date` is returned in plaintext.

```typescript TypeScript theme={null}
type EncryptedCardDetails = {
  card_number: string;
  cvv: string;
  expiration_date: string;
  iv: string;
};

async function revealCardDetails(cardId: string) {
  const keyResponse = await fetch("/api/payment-cards/pci/public-key", {
    credentials: "include",
    cache: "no-store",
  });
  if (!keyResponse.ok) throw new Error("Could not load encryption keys");

  const material = (await keyResponse.json()) as PciPublicKeyResponse;
  const session = await createSession(material);
  const query = new URLSearchParams({
    encrypted_key: session.encryptedKey,
    key_id: session.keyId,
  });

  const response = await fetch(
    `/api/payment-cards/${encodeURIComponent(cardId)}/details?${query}`,
    { credentials: "include", cache: "no-store" },
  );
  if (!response.ok) throw new Error("Could not load card details");

  const encrypted = (await response.json()) as EncryptedCardDetails;
  return {
    cardNumber: await decryptAesGcm(
      encrypted.card_number,
      encrypted.iv,
      session.aesKey,
    ),
    cvv: await decryptAesGcm(encrypted.cvv, encrypted.iv, session.aesKey),
    expirationDate: encrypted.expiration_date,
  };
}
```

The response contains one IV for both encrypted fields. Use that IV for both decryptions, and never reuse the session key for another request.

## Retrieve the PIN

PIN retrieval uses the same key setup, but calls `GET /pci/cards/{card_id}/pin`. Decrypt `encrypted_pin`, then parse the result as an ISO 9564 format 2 PIN block.

```typescript TypeScript theme={null}
type EncryptedPin = {
  encrypted_pin: string;
  iv: string;
};

function decodePinBlock2(block: string): string {
  if (block.length !== 16 || block[0] !== "2") {
    throw new Error("Invalid PIN block");
  }

  const length = Number(block[1]);
  const pin = block.slice(2, 2 + length);
  const padding = block.slice(2 + length);
  if (length < 4 || length > 6 || !/^\d+$/.test(pin) || !/^F+$/.test(padding)) {
    throw new Error("Invalid PIN block");
  }
  return pin;
}

async function revealPin(cardId: string, material: PciPublicKeyResponse) {
  const session = await createSession(material);
  const query = new URLSearchParams({
    encrypted_key: session.encryptedKey,
    key_id: session.keyId,
  });

  const response = await fetch(
    `/api/payment-cards/${encodeURIComponent(cardId)}/pin?${query}`,
    { credentials: "include", cache: "no-store" },
  );
  if (!response.ok) throw new Error("Could not load the PIN");

  const encrypted = (await response.json()) as EncryptedPin;
  const pinBlock = await decryptAesGcm(
    encrypted.encrypted_pin,
    encrypted.iv,
    session.aesKey,
  );
  return decodePinBlock2(pinBlock);
}
```

> [!NOTE]
>
> Create a new session for the PIN request, even if the user has just retrieved card details.

## Set a PIN

Accept a PIN of 4 to 6 digits in the UI and ask the user to confirm it. Convert it to an ISO 9564 format 2 PIN block, encrypt it with a fresh AES key and IV, and send only the encrypted request to your backend.

```typescript TypeScript theme={null}
type SetEncryptedPinRequest = {
  encrypted_key: string;
  encrypted_pin: string;
  iv: string;
  key_id: string;
};

function encodePinBlock2(pin: string): string {
  if (!/^\d{4,6}$/.test(pin)) {
    throw new Error("PIN must contain 4 to 6 digits");
  }
  return `2${pin.length}${pin}`.padEnd(16, "F");
}

async function encryptPin(
  pin: string,
  material: PciPublicKeyResponse,
): Promise<SetEncryptedPinRequest> {
  const session = await createSession(material);
  const ivBytes = crypto.getRandomValues(new Uint8Array(12));
  const encryptedPin = await crypto.subtle.encrypt(
    { name: "AES-GCM", iv: ivBytes, tagLength: 128 },
    session.aesKey,
    encoder.encode(encodePinBlock2(pin)),
  );

  return {
    encrypted_key: session.encryptedKey,
    encrypted_pin: bytesToBase64(new Uint8Array(encryptedPin)),
    iv: bytesToBase64(ivBytes),
    key_id: session.keyId,
  };
}

async function setPin(
  cardId: string,
  pin: string,
  material: PciPublicKeyResponse,
) {
  const encrypted = await encryptPin(pin, material);
  const response = await fetch(
    `/api/payment-cards/${encodeURIComponent(cardId)}/pin`,
    {
      method: "POST",
      credentials: "include",
      cache: "no-store",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(encrypted),
    },
  );

  if (!response.ok) throw new Error("Could not update the PIN");
}
```

Your backend forwards this body to `POST /pci/cards/{card_id}/pin` and returns only the result of the operation.

## Safeguards

Apply all of these before going live.

> [accordion] Backend
>
> * Authenticate the end user and check that they may access the requested `card_id` before every proxy call.
> * Keep the Partner API access token on your backend. Never return it to the browser, and never accept a tenant or partner scope from the UI.
> * Don't decrypt card data or PINs on your backend. Forward encrypted responses without changing them.

> [accordion] Logging and caching
>
> * Redact query strings and request and response bodies for these routes from application, proxy, tracing, analytics, and error logs.
> * Return `Cache-Control: no-store`, and exclude these routes from browser, CDN, reverse-proxy, and service-worker caches.

> [accordion] UI
>
> * Keep session keys, decrypted values, and PIN input only in memory. Never write them to local storage, session storage, IndexedDB, persisted app state, crash reports, or analytics.
> * Show sensitive values only after an explicit user action, and only briefly. Mask them again when the page loses focus, the user navigates away, or the display timer runs out.
> * Clear PIN inputs and references to decrypted values and keys after success, failure, or cancellation.
> * Turn off session replay and DOM capture on every view that can show sensitive card data.

## Next step

> [card] [Handle 3DS challenges](https://docs.gnosispay.com/platform-integration/cards/cards-3ds.md)
>
> Respond to 3D Secure verification during online payments.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.