Decrypt card secured details
Card credentials are returned encrypted. The card number and CVV are sealed with a public key you register with us, so no readable PAN passes through the gateways, proxies, log stores, or error trackers that handle the response in transit.
Decryption takes place in your own systems, using the private key that pairs with the registered public key. We never receive that private key and cannot decrypt the response ourselves.
Setup is two steps: generate an RSA key pair, then register the public key in the dashboard. After that, every card details response is decrypted in your own code.
Key pair roles
Setup produces one RSA key pair: two files with distinct roles. Mixing them is the most common cause of integration failure.
bitnob-cards-public.pem | bitnob-cards-private.pem | |
|---|---|---|
| Function | Encrypts the response | Decrypts the response |
| Distribution | Registered with us in step 2 | Never disclosed, to us or anyone |
| Storage | No restrictions | Secret manager |
Only the public key leaves your environment. It encrypts each response; the corresponding private key is the only key that can decrypt one. Decryption always uses the private key setup is three steps and takes about five minutes.
Disclosure of the private key voids the protection entirely: any holder can decrypt every response encrypted under the matching public key. We do not retain a copy and cannot restore it. If the key is lost or compromised, generate a new pair and register the new public key.
Step 1 - Generate your keys (once)
Generate an RSA key pair of 2048 bits or greater. The five implementations below are equivalent — select a language from the dropdown. On Windows use JavaScript, Python, Go or PowerShell; the Shell option needs openssl, which Windows does not install by default.
Store the private key
Move bitnob-cards-private.pem into your secret manager and delete the local copy. Pipe the file directly rather than printing and pasting it the key is not recoverable if lost, and we hold no copy.
At decryption time, read the key from the secret store rather than from the repository or a build image. The decryption examples load the key from the path in BITNOB_CARDS_PRIVATE_KEY_PATH.
Verify the key format
Confirm the first line of the public key before proceeding. An incorrect header is the most common cause of rejection in step 2:
The output must be -----BEGIN PUBLIC KEY-----. Any other header — -----BEGIN RSA PUBLIC KEY-----, for instance — is rejected in step 2; see when something goes wrong for the conversion.
Step 2 — Register the public key
The public key is registered from the dashboard, under Settings → Controls → Card encryption key. There is no API for this step — the key cannot be submitted programmatically.
Open Settings in the dashboard.
Select the Controls tab.
Select Card encryption key.
Click on Add new key, paste the contents of bitnob-cards-public.pem into the field, and save.
To skip the navigation, open Settings → Controls → Card encryption key directly.
What to paste
The whole file, both armour lines included, with the line breaks exactly as they appear in the file:
The armour lines are how the key is located at all; a value without them is rejected regardless of how well formed the rest is. Line breaks inside the base64 body are optional wrapped at 64 characters (what the step 1 generators produce) and a single unbroken run are both accepted.
Copy the file; do not retype or hand-edit it. Every rejection listed in when something goes wrong comes from a key that was reassembled by hand or truncated in transit. Do not paste a JSON-escaped value either — literal \n sequences are not valid base64 and will be rejected.
Confirm the key is complete
A 2048-bit public key from step 1 always has the same shape, so a truncated paste is visible without running anything:
Nine lines — the two armour lines and seven lines of base64.
The body ends IDAQAB. This encodes the public exponent 65537, which every key generated in step 1 uses.
If the value is shorter, or does not end that way, it was clipped between the file and the dashboard — copy the file again.
On macOS, Linux, or Git Bash you can count the base64 body directly. It is 392 characters:
Replace /path/to/ with the folder you generated the key in. The ----- is not a placeholder — it is the text grep matches to strip the BEGIN and END lines, leaving only the base64 body to count.
Registering a key again replaces the stored one, which is how you rotate. See rotating your key.
Registering the key is a prerequisite, not a feature toggle. Until a valid public key is stored we have nothing to encrypt to, so requests for card details fail with 400 — no encryption key is registered rather than returning anything readable.
Decrypting a response
Decryption uses the private key — the half that never left your machine. The example below fetches a card and returns the plain details; select a language from the dropdown. It depends only on the generateAuthHeaders helper from Authentication.
This completes the integration. Validate it against the test vector before calling the endpoint with a live card.
Test vector
A decryption failure against a live card has two possible causes — a defect in your decryption code, or an incorrect key registration — and both present identically. The vector below isolates them. It is a genuine encrypted payload built with the scheme documented on this page, and requires no API call: if your code decrypts it successfully, your implementation is correct and the fault lies in registration.
The private key below is published intentionally and is for testing only. It secures nothing. Do not use it in your integration, and do not confuse it with bitnob-cards-private.pem, which you generated in step 1 and must never disclose.
Feed your decrypt function this encrypted_details object, using the key above:
Passing both to your decrypt function must produce exactly this, byte for byte:
If the vector decrypts but a live card does not, the implementation is correct and the registration is at fault; see check your key pair. If the vector fails, the defect is in your decryption code; see when something goes wrong.
What you get back
Every response carries data.encrypted_details:
data.balance_amount is a STRING. The API serializes 64-bit integers as JSON strings, so it arrives as "5000000", not 5000000. Convert it (Number(...) / int(...)) before doing arithmetic. The copy inside the encrypted blob is a real number — see below.
What decryption gives you
Decrypting encrypted_details yields the card itself, with the balance carried alongside it as a real number:
The balance is therefore available two ways: from data.balance_amount without decrypting anything (convert the string), or from the decrypted object (already a number).
data.balance_amount and data.balance_currency sit outside the encrypted blob deliberately, so you can show a balance without decrypting anything.
When something goes wrong
Decryption fails
| What you see | Cause |
|---|---|
RSA unwrap fails / oaep decoding error | The OAEP hash is SHA-1. It must be SHA-256 — in Node, pass oaepHash: 'sha256'. This is the most common cause |
| RSA unwrap fails and the hash is right | Wrong private key — it is not the mate of the public key you registered. See check your key pair |
Unsupported state or unable to authenticate data / GCM auth failure | The nonce or tag was split wrong. The layout is nonce(12) ‖ ciphertext ‖ tag(16) |
| The plaintext is not valid JSON | base64-decoded twice, or the two fields were concatenated |
You are trying to decrypt with bitnob-cards-public.pem | The public key only encrypts. Decryption uses the private half — see the overview |
A GCM authentication failure is never safe to work around by skipping the tag check. It means the bytes are not the ones we sealed.
If you have not already done so, run the test vector to establish whether the fault lies in your decryption code or in your key registration.
Registration is rejected
| What you see | Cause |
|---|---|
400 — "no encryption key is registered" | No key is stored for your account, so there is nothing to encrypt to. Complete step 2 |
400 — "invalid RSA public key" | Wrong PEM. It must begin -----BEGIN PUBLIC KEY----- and be 2048-bit or larger |
503 | We could not read your registered key. Transient — retry |
no PEM block found
Every value below is rejected with the same message. All of them come from hand-editing the key instead of copying the file verbatim:
| Value | Why |
|---|---|
MIIBIjANBg… | no armour lines |
\nMIIBIjANBg…\n | escaped newlines, but still no armour |
-----BEGIN PUBLIC KEY-----MIIB…-----END PUBLIC KEY----- | flattened onto one line; the breaks after BEGIN and before END are missing |
-----BEGIN PUBLIC KEY-----\n⏎MIIB… | a JSON-escaped value was pasted — the literal \ and n are not base64 |
-----BEGIN RSA PUBLIC KEY-----… | PKCS#1, not SPKI. Convert it: openssl rsa -RSAPublicKey_in -in pkcs1.pem -pubout -out spki.pem |
Check your key pair
If the test vector passes but a live card fails, the private key you are decrypting with is probably not the mate of the public key we hold. The two hashes must match:
If they differ, the two files are from different pairs — regenerate (step 1) and register the new public key (step 2).
If the hashes match, the key registered with us may differ from the file you hold. Re-register bitnob-cards-public.pem to confirm the stored key corresponds to your private key.
Handling the decrypted details
The decrypted payload contains a live card number and CVV. PCI scope applies to it and to any system it reaches.
Never log it, at any level. Log the card_id instead.
Do not persist it. Retrieve the details again when required; we do not store them either.
Keep it out of URLs, query strings, analytics and error trackers.
Return it to a browser only over TLS, and only to the cardholder.
Rotating your key
Generate a new pair (step 1) and register the new public key (step 2); it replaces the old one.
Rotation is not retroactive: a response already encrypted to the old key can only be opened with the old private key. Keep the previous private key until you are certain nothing you fetched under it is still in flight.
How it works
Envelope encryption, the same shape as JWE. RSA cannot encrypt a payload this size — a 2048-bit OAEP-SHA256 key holds at most 190 bytes and the details JSON is larger — so:
| Field | Contents |
|---|---|
encrypted_key | a fresh 32-byte AES-256 key, wrapped with your RSA public key using OAEP with SHA-256 |
encrypted_data | the details JSON under AES-256-GCM, laid out as nonce ‖ ciphertext ‖ tag |
| nonce 12 bytes at the front · tag 16 bytes at the end · no AAD |
Both fields are standard, padded base64. The AES key is generated per request and never stored on our side.
We hold only your public key, so we cannot decrypt what we send you — by design, not by policy.