Your decryption key

The one key that reads your users' data — generating it, installing it, checking it after every deploy, storing nothing per user, and replacing it without losing anything.

A documentação para parceiros é publicada em inglês.

#Why you hold the key

Everything the API returns about a user — accounts, balances, transactions, the bank-side identifiers a sync needs — is an encrypted envelope. open-banking.io stores ciphertext it cannot read. For a user who connects through your Connect client, the envelopes are sealed to your key: a P-256 pair whose public half we hold and whose private half lives only in your deployment.

That is what makes the journey short. The user signs in, approves your client by name, meets their bank, and lands on your callback. They never invent a passphrase, never unlock anything, and can finish in one visit without an open-banking.io account of their own. It is also what makes the key mandatory: without it a person's data would have nowhere safe to go, so until it is installed you cannot register a Connect client (409 recipient_key_required) and every /oauth/authorize answers temporarily_unavailable with an error_description naming the missing key.

One key per deployment, for every user, for as long as you keep it. Lose the private half and the data of every user who connected under it is unreadable — by you, by them, and by us. There is no reset and no recovery; a new key does not recover anything sealed to the old one.

#Generate and install it

Open your partner page at /app/partner#decryption-key and press Generate a key in this browser. The page makes the pair with WebCrypto, shows you the private half once, and then:

  1. Download the key file — open-banking-io-decryption-key.json — or copy the private half, and put it where your deployment reads its secrets. The file is JSON; the private half is encryptionKey.privateKey, base64 PKCS#8. (The CLI below writes the same shape as recipient-key.json.)
  2. Tick the acknowledgement — the sentence about recovery is the point of the step.
  3. Press Install decryption key. The page asks the server for a possession challenge, opens it with the private half it is still holding, and installs the public half. The private half reaches no request.

The card then shows the key's fingerprint: the first 16 hex characters of SHA-256 over the raw 65-byte public point. Write it down; the next section is about it.

#Bring your own key

A key generated elsewhere works the same way, through the same card: paste the public half (the raw point, base64, starting with B), press Check the new key, open the envelope you are shown with the private half, and paste the result back. The envelope is a v1 zero-knowledge envelope of { "nonce": "…" }, valid for 15 minutes and bound to that exact key. Any of these opens it (the CLI commands and the Node helpers ship with the Node client 1.2.0 release of open-banking-io/clients):

sh

brew install open-banking-io/tap/openbanking     # or: go install github.com/open-banking-io/clients/cli@latest
openbanking partner key generate                 # writes recipient-key.json (0600) and prints the public half
openbanking partner key answer --key recipient-key.json "<envelope>"   # prints the nonce

ts

import { answerRecipientKeyChallenge, generateRecipientKeyPair } from '@open-banking-io/client'; // 1.2.0

const pair = await generateRecipientKeyPair();            // { privateKeyPkcs8Base64, publicKeyRawBase64 }
const nonce = await answerRecipientKeyChallenge(pair.privateKeyPkcs8Base64, envelope);

js

import { webcrypto } from 'node:crypto';                    // no SDK

const pair = await webcrypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits']);
const publicKey = Buffer.from(await webcrypto.subtle.exportKey('raw', pair.publicKey)).toString('base64');   // install this
const privateKey = Buffer.from(await webcrypto.subtle.exportKey('pkcs8', pair.privateKey)).toString('base64'); // keep this

Behind the card are two calls, both taking the partner owner's signed-in session (not an API key, which is why the CLI generates and answers but does not install): POST /api/partner/recipient-key/challenge with { "publicKey" } returns { "fingerprint", "challengeToken", "envelope", "expiresInSeconds" }; PUT /api/partner/recipient-key with { "publicKey", "challengeToken", "challengeAnswer" } installs it. Re-sending the key you already have needs no proof and changes nothing but the setAt it records. The reference lists the refusals.

Never paste the private half anywhere on open-banking.io. The card refuses PEM, PKCS#8, SEC1 and JWK private keys, and our own key file — but the refusal is a courtesy, not a guarantee.

#Verify the fingerprint after every deploy

The mistake to guard against is installing one key while your deployment holds another. The two halves live on independently deployable sides, and nothing detects a mismatch until a read fails to decrypt — by which time the data is sealed to a key you do not have. So print the fingerprint of the key your process actually loaded, at boot, and compare it with the one on the partner page after every deploy:

js

import { createHash, createPublicKey } from 'node:crypto';

const pub = createPublicKey({ key: Buffer.from(RECIPIENT_PRIVATE_KEY, 'base64'), format: 'der', type: 'pkcs8' })
  .export({ format: 'der', type: 'spki' }).subarray(-65);
console.log('decryption key', createHash('sha256').update(pub).digest('hex').slice(0, 16));

With the Node client 1.2.0 that is recipientKeyFingerprint(await recipientPublicKey(RECIPIENT_PRIVATE_KEY)); with the CLI of the same release, openbanking partner key fingerprint recipient-key.json. The reference carries a worked example — a public key and its fingerprint — you can run any of these against.

#What to store per user

The token, and the user subject it reads for. That is all.

js

await bundles.put(sessionId, { token });           // token = the /oauth/token response

There is no key per user: every envelope for every user who connected under your key opens with the same private half your deployment already holds. The one exception is the pre-key cohort below, whose relay still carries a privateKey; keep that string next to their token when it is present, and nowhere otherwise.

js

await bundles.put(sessionId, { token, legacyPrivateKey: relay.privateKey || undefined });

#Decrypting

The SDKs decrypt for you. Build the client from the token and your key — or the user's own, for the few who have one:

js

import { OpenBankingClient } from '@open-banking-io/client';

const { token, legacyPrivateKey } = await bundles.get(sessionId);
const client = OpenBankingClient.fromTokenResponse(token, legacyPrivateKey ?? RECIPIENT_PRIVATE_KEY);
const accounts = await client.getAccounts();

Without an SDK: envelopes are version(1) | ephemeralPublicKey(65) | nonce(12) | tag(16) | ciphertext, ephemeral ECDH on P-256 → HKDF-SHA256 (32 zero bytes of salt, info bank.core.ci/zk/v1) → AES-256-GCM, and every SDK's test fixtures spell it out byte for byte. The reading data page lists what there is to decrypt.

#Replacing your key

Replacing is neither instant nor a revocation, and it cannot be undone.

  • A user moves onto the new key at their next Connect (the next token exchange), not when you press Replace.
  • Their data follows only when a later sync re-fetches that account's full history. That sync re-seals every row it fetched to the new key and deletes the rows it could not fetch, atomically — so after it an account opens with the new key only. At a bank that will not serve a full history — some cap it at 90 days — the sync is refused and retried rather than importing a partial statement, so an account can stay sealed to the old key indefinitely.
  • We do not yet report which accounts have moved.

So keep the old key until every account has moved, and decrypt with whichever of the two opens an envelope. Reconnecting every user is not the finish line. The key cannot be removed, only replaced; the partner page asks you to confirm you hold both before it replaces anything.

#Users who connected before your key

A user who connected through your client before you installed a key keeps the key their browser made, and keeps relaying it on every Connect. Their data is sealed to that key, not yours; taking it away would leave everyone unable to read it. So the relay carries privateKey for them and an empty string for everyone else — a key in the relay is theirs, an empty one means yours. A partner that installed its key before its first user (every partner approved since September 2026) never sees one.

The published Node client 1.1.0 throws missing_private_key on the empty one — catch that code and carry on; it is checked last, after state, iss, the error branch and code. 1.2.0 returns the relay either way with parseRelay(..., { expectPrivateKey: 'optional' }).

#Migrating from relayed user keys

Integrated before September 2026, storing one relayed key per user? In this order:

  1. Deploy the empty-key handling first. The moment your key is installed, every new relay arrives with privateKey empty, and the Node client 1.1.0 throws missing_private_key on it. Catch exactly that code (or, on 1.2.0, pass expectPrivateKey: 'optional') — before you press Install, not after.
  2. Install the key. Your existing Connect clients keep working; the requirement bites only on registering a new one.
  3. Keep the per-user key store for as long as that data exists. Users who connected before the key never move onto it: their data stays sealed to the key their browser made, and their relay keeps carrying it. A non-empty privateKey in the relay is the only signal that a user is one of them; key that store by token.user, not by session, and stop writing to it for everyone else.
  4. Remove the passphrase from your product. Your onboarding copy, screenshots and support answers that mention "choose a passphrase" or "unlock your key" no longer describe what a new user sees.

#Staging and production

Staging is a separate environment with its own partner record, so it needs its own key, installed on https://staging.open-banking.io/app/partner. Generate a separate pair for it: the production private half never belongs on a staging host, and a staging key installed on production would seal real users' data to a key kept in the wrong place.

#The key is a credential

Treat the private half exactly like the client secret: no logs, no URLs, no analytics, no third parties, and exclude nothing that loads it from your secret store's audit trail. It is worth more than the client secret — a leaked secret can be rotated; a leaked key reads every user's history until each of them has reconnected and re-synced under a new one.

Because the key is yours, so is ending the bank's permission when a user disconnects: revoking the token stops you reading, but closing the consent at the bank needs the Enable Banking session id, which is sealed to your key. Tokens and revocation shows the one call that does both.

#The three private keys

You will meet three unrelated private keys in this documentation, and only one of them is yours to keep: this one, the recipient key. The other two are the relayed user key of the pre-key cohort above, and the Enable Banking application key, which we generate and hold for your application and which never leaves the server. The glossary keeps them apart.

  • Quickstart — the key, a client and a running server in under an hour.
  • Callback — the handler that stores { token } and decrypts with your key.
  • Security checklist — what to tick before the first production user.