Encrypted FinanceRates 7 min read
When a TPP holds the ReadProductFinanceRates permission and calls GET /accounts/{AccountId}/product for a credit card, finance, or mortgage product, the LFI MAY return the FinanceRates field as a JWE rather than a cleartext object. This guide covers how to generate the one-time code, deliver it to the customer, build the JWE, and rate-limit the endpoint so the customer's encrypted rates remain protected end-to-end.
Encryption is an LFI choice, scoped to one field
The FinanceRates field on GET /accounts/{AccountId}/product is defined as anyOf a structured AEProductFinanceRates object or an AEJwe compact string. LFIs MAY decide, per product, whether to return the rate in cleartext or as a JWE. Encryption is typically applied to credit cards, finance accounts, and mortgages where the finance rate is commercially sensitive; deposit account interest rates are returned in cleartext.
- The LFI MUST NOT encrypt any field other than
FinanceRateson this endpoint.Charges,DepositRates,ProductName,Tenor, and every other property stay cleartext on both the encrypted and unencrypted shapes. - The LFI MUST NOT encrypt any data from any other Open Finance endpoint — this mechanism exists solely for
FinanceRatesonGET /accounts/{AccountId}/product. - A single LFI MAY choose to encrypt for some product types and not others. The TPP detects the shape at runtime by checking whether
FinanceRatesis an object or a string — the LFI does not need to advertise its choice ahead of time.
Three things happen on every encrypted call
- Generate a one-time code (OTP) — a fresh, cryptographically random numeric code, scoped to this single call.
- Deliver the OTP to the customer — the LFI actively pushes the code to the customer on a channel it controls (SMS, email, or a push notification in the LFI's mobile banking app). The customer is sent the code; they never have to go and retrieve it.
- Encrypt the cleartext
FinanceRatesas a JWE with the OTP as the password and substitute the JWE string for the cleartext object in the response body. The cleartext rates never leave the LFI.
Because the OTP is the decryption key, the customer reading the code the LFI sent them and typing it into the TPP application is exactly what makes the rate visible. The TPP server never sees the OTP, and the LFI never sees the TPP's decryption code. The customer is the only party that holds both the JWE (via the TPP) and the key (delivered by the LFI).
No ReadProductFinanceRates → omit the field entirely
Before doing anything else — before deciding to encrypt, before generating an OTP, before delivering the code, before building a JWE — the LFI MUST check that the consent underlying this request includes ReadProductFinanceRates. If the permission is absent, the LFI MUST omit the FinanceRates field from the response entirely. The rest of the product payload (Charges, DepositRates, ProductName, etc.) is returned as usual.
How to read the permission
The API Hub sets the o3-consent-id header on every Ozone Connect call made under a consent. That ID identifies the consent the customer authorised; the LFI's job is to retrieve the consent, read the Permissions array off it, and check that ReadProductFinanceRates is present. The LFI does NOT re-validate the access token — that is the Hub's responsibility.
There are two valid ways to obtain the consent record. Pick whichever fits your architecture; both are first-class:
| Option | How it works | When to choose it |
|---|---|---|
| A — Local consent store | The LFI persists consent records locally as the Hub calls /consent/event/{operation} during the consent-events flow at authorization. At request time the LFI looks up the consent by ID in its own store. | You already maintain a consent table for revocation handling, account-list patching, or customer attribution. Lowest per-request latency. |
| B — Fetch from the Hub | The LFI calls GET /consents/{consentId} on the Hub's Consent Manager (https://cm.{LFICODE}.apihub.openfinance.ae) to fetch the consent on demand. The response includes the Permissions array. | You do not maintain a local consent store, or you do but treat the Hub as the source of truth. Adds one Hub round-trip per request unless you cache the response. |
Whichever option you use, the check itself is the same: permissions.includes('ReadProductFinanceRates'). If false — omit FinanceRates. If true — proceed to the cleartext or encrypted path based on LFI policy for the product type.
The Hub's GET /consents/{consentId} response is safe to cache for the lifetime of the consent — the Permissions array is set at authorization and never mutates. Cache invalidation comes from the consent-events flow: when the Hub notifies your /consent/event/{operation} endpoint that a consent has been revoked or modified, drop the cached entry. A short positive TTL (e.g. 5 minutes) is a reasonable defence against missed events.
- The check applies equally to the cleartext and encrypted paths. If
ReadProductFinanceRatesis missing, the LFI MUST NOT return cleartext rates and MUST NOT trigger the code-generation, delivery, or JWE flow. - When the field is omitted, do not substitute
null, an empty object, or a placeholder JWE. TheFinanceRateskey simply does not appear on theProductobject. - The same
o3-consent-idheader is used to attribute the call for logging, rate-limit accounting (Step 6), and audit. Read it once and carry it through the request handler.
import type { Request } from 'express'
const HUB_CONSENT_MANAGER_BASE = process.env.HUB_CONSENT_MANAGER_BASE!
// e.g. 'https://cm.altareq1.apihub.openfinance.ae'
// Option A — read from your local consent store.
// You populated it via /consent/event/{operation} during the consent-events
// flow at authorization time.
function readPermissionsLocal(consentId: string): ReadonlyArray<string> {
const consent = consentStore.get(consentId)
return consent.Permissions
}
// Option B — fetch the consent from the Hub's Consent Manager.
// Authenticate using your LFI's Ozone Connect JWT credentials.
async function readPermissionsFromHub(consentId: string): Promise<ReadonlyArray<string>> {
const response = await fetch(
`${HUB_CONSENT_MANAGER_BASE}/consents/${consentId}`,
{
headers: {
Authorization: `Bearer ${await getOzoneConnectJwt()}`,
},
},
)
if (!response.ok) {
throw new Error(`Hub /consents/${consentId} failed: ${response.status}`)
}
const consent = await response.json()
return consent.Permissions
}
// Pick whichever fits your architecture; both are first-class.
const readPermissions = readPermissionsLocal // or readPermissionsFromHub
async function buildFinanceRates(
req: Request,
product: Product,
): Promise<AEProductFinanceRates | EncryptedFinanceRates | undefined> {
const consentId = req.header('o3-consent-id')
if (!consentId) {
// Defensive — the Hub always sets this on Ozone Connect calls under a
// consent. A missing header is a misconfiguration, not a customer issue.
throw new Error('o3-consent-id header missing on Ozone Connect request')
}
const permissions = await readPermissions(consentId)
// Gate the entire FinanceRates field on this single permission.
if (!permissions.includes('ReadProductFinanceRates')) {
// Omit the field entirely. Do not generate an OTP, do not deliver a code,
// do not build a JWE — and do not return an empty object placeholder.
return undefined
}
// Permission is present — choose between cleartext and the encrypted path
// based on LFI policy for this product type.
return shouldEncrypt(product)
? encryptedFinanceRatesFor(product)
: product.financeRates
}
Delivering an OTP for a request that should not have received FinanceRates in the first place is both wasteful and confusing for the customer (they receive a code for a value the TPP cannot legitimately show). Putting the permission check first eliminates this class of bug entirely.
A fresh 6-digit code per request
Each call to GET /accounts/{AccountId}/product that returns encrypted FinanceRates MUST mint a new OTP. The OTP is bound to a single JWE for the full 30-minute display window — the TPP MAY re-decrypt the same JWE multiple times with the same OTP within that window, but a new call to the endpoint MUST produce a fresh OTP and a fresh JWE.
| Property | Requirement |
|---|---|
| Format | 6 digits, numeric. Leading zeros MUST be preserved in transit. |
| Source | Cryptographically secure RNG (e.g. crypto.randomInt in Node.js, secrets.randbelow in Python). MUST NOT use Math.random, the default random module, or any non-CSPRNG source. |
| Reusability | The OTP is reusable for decryption attempts within the 30-minute JWE window. A new call to GET /accounts/{AccountId}/product MUST issue a fresh OTP, even if the previous JWE has not yet expired. |
| Storage | The OTP MUST NOT be persisted in cleartext at the LFI after the JWE has been built. Hold it only long enough to use it as the PBES2 password and deliver it to the customer, then discard. |
| Logging | The OTP MUST NOT appear in application logs, audit trails, request traces, monitoring tools, or any other system the LFI operates. |
import crypto from 'node:crypto'
// 6-digit numeric OTP drawn from a cryptographically secure RNG.
// Range is [0, 999999]; pad to a fixed 6-character string so leading
// zeros are preserved when the customer reads the code.
function generateOtp(): string {
return crypto.randomInt(0, 1_000_000).toString().padStart(6, '0')
}
The LFI delivers the code on a channel it controls
The LFI MUST deliver the OTP to the customer directly, through a channel the LFI controls and the customer can reach without involving the TPP. The LFI chooses the channel: an SMS to the customer's registered mobile number, an email to their registered address, or a push notification or message in the LFI's own mobile banking app are all acceptable. The LFI MUST NOT deliver the OTP through the TPP — the entire point is that the TPP never holds the decryption key.
Whichever channel the LFI uses, it MUST actively deliver the code to the customer — the customer must be sent the code, not asked to go and find it. A push notification that surfaces the code, or that deep-links the customer straight to it, meets this bar; a design that requires the customer to independently open the banking app and hunt for the code does not. The LFI provides the code to the customer; the customer never has to retrieve it.
The customer is mid-journey in the TPP application, waiting for a code to arrive. The delivery channel MUST push the code to them. A model where the customer must leave that journey, authenticate somewhere else, and locate the code themselves is not acceptable — it breaks the flow and the customer cannot reasonably complete it.
Message content requirements
- The OTP itself.
- The LFI's brand name as a recognisable sender or in the first line of the body, so the customer can identify who sent the message.
- The TPP's
TradingName(from the consent), so the customer knows which app to enter the code into. This is the part of the message the customer uses to bridge the LFI-sent message with the TPP-presented form. - The product name or a short product description, so the customer understands which rate they are about to view.
- An expiry indication — "Valid 30 min" is the canonical wording.
- An explicit anti-fraud line for the customer who did not start this journey — "If you didn't request this, ignore this message and never share this rate." The wording matters: it tells an unsuspecting customer to disregard the message, and warns every customer never to read the code aloud to anyone — while leaving them free to type it into the TPP form themselves.
The message MUST NOT include the cleartext finance rate, any link inviting the customer to continue their journey somewhere other than the TPP application, or any other product data beyond the brand, code, TPP name, product description, and expiry.
Message template
A recommended template the LFI substitutes from the request context — the TPP_TRADING_NAME comes from the consent's TPP organisation registration, the PRODUCT_NAME from the product the call is for, and OTP from Step 2. The example is written for SMS; the same content requirements apply whatever channel the LFI delivers on:
{LFI_BRAND}: You requested your {PRODUCT_NAME} finance rate via {TPP_TRADING_NAME}. Your code is {OTP}. Valid 30 min. If you didn't request this, ignore this message and never share this rate.
Example SMS as the customer receives it
ALTAREQ BANK: You requested your Platinum Credit Card finance rate via BudgetBuddy. Your code is 482915. Valid 30 min. If you didn't request this, ignore this message and never share this rate.
The wording is intentionally explicit about where the code is to be entered — the message arrives from the bank but the form sits inside the TPP application, and the customer needs to bridge those two contexts. Naming the TPP makes that connection.
The example above is English. LFIs SHOULD localise the message to the customer's registered language preference; in practice this usually means English alongside Arabic. The content requirements above apply to every localised variant.
PBES2-HS512+A256KW with the OTP as the password
The cleartext FinanceRates object is encrypted as a JOSE compact JWE using PBES2-HS512+A256KW for key wrapping and A256GCM for content encryption. PBES2 takes the OTP as a password and derives the content-encryption key inside the JWE library; the LFI never needs to handle key material directly.
| JWE parameter | Value | Notes |
|---|---|---|
alg | PBES2-HS512+A256KW | Key wrapping. PBES2 is the JOSE-native way to use a password (the OTP) as the input. |
enc | A256GCM | AES-256-GCM content encryption with a 96-bit IV. Authenticated encryption — tampering is detected on decrypt. |
p2c | 600000 (minimum) | PBKDF2 iteration count. OWASP recommends at least 600,000 iterations for HMAC-SHA-512 as of 2023. Higher is acceptable. |
p2s | Generated by the JWE library | PBKDF2 salt. The library generates a fresh 16-byte salt per call and emits it in the protected header. |
kid | OneTimeCode | Signals to the TPP that the PBES2 password is the OTP entered by the customer, not a static key. |
Resulting protected header (decoded)
{
"alg": "PBES2-HS512+A256KW",
"enc": "A256GCM",
"p2c": 600000,
"p2s": "4kAX-NQVF8...",
"kid": "OneTimeCode"
}
JWT-style expiry inside the JWE payload
The JWE's PBES2 envelope does not itself express an expiry, and the OTP does not stop working once 30 minutes have passed — a JWE and its OTP can technically be decrypted at any later time. The 30-minute limit is therefore not enforced by the cryptography. It is enforced by the exp claim the LFI embeds in the plaintext, which the TPP MUST honour, backed by the TPP's Access Encrypted Resource Data certification obligations. The LFI embeds iat and exp inside the JSON plaintext alongside the FinanceRates object — so the TPP can stop displaying the rate and show a "this code has expired" message once the window closes — and MUST set exp = iat + 1800 seconds exactly; longer windows are not permitted.
import { CompactEncrypt } from 'jose'
const JWE_TTL_SECONDS = 30 * 60 // 30 minutes — normative
async function encryptFinanceRates(
rates: AEProductFinanceRates,
otp: string,
): Promise<string> {
const now = Math.floor(Date.now() / 1000)
// Wrap the cleartext rates with iat / exp so the TPP can show
// a helpful "this code has expired" message after the window closes.
const payload = JSON.stringify({
FinanceRates: rates,
iat: now,
exp: now + JWE_TTL_SECONDS,
})
return await new CompactEncrypt(new TextEncoder().encode(payload))
.setProtectedHeader({
alg: 'PBES2-HS512+A256KW', // key wrapping
enc: 'A256GCM', // content encryption
p2c: 600_000, // PBKDF2 iterations (OWASP 2023+ minimum for SHA-512)
kid: 'OneTimeCode', // signals to the TPP that the password is the OTP
})
.encrypt(new TextEncoder().encode(otp))
}
The cleartext rate is the very thing this scheme exists to protect from accidental disclosure. Encryption MUST happen in process memory; the cleartext rate, the OTP, and the derived key material MUST NOT be written to logs, traces, dumps, or any storage tier. Treat them with the same hygiene as a customer's password.
Swap the structured object for the JWE string
The OpenAPI schema makes the response trivial to assemble: FinanceRates is either an AEProductFinanceRates object or an AEJwe string. Replace the cleartext object with the compact JWE produced in Step 4 and leave every other field untouched.
// Build the GET /accounts/{AccountId}/product response.
// Only the FinanceRates field is encrypted — every other field
// (Charges, DepositRates, Tenor, ProductName, ...) stays cleartext.
function buildProductResponse(
account: Account,
product: Product,
encryptedFinanceRatesJwe: string | undefined,
) {
return {
Data: {
Product: [
{
AccountId: account.id,
ProductId: product.id,
ProductType: product.type,
ProductName: product.name,
Charges: product.charges,
DepositRates: product.depositRates,
Tenor: product.tenor,
// FinanceRates is either the structured object (cleartext)
// or the compact JWE string (encrypted). Never both, never null.
FinanceRates: encryptedFinanceRatesJwe ?? product.financeRates,
},
],
},
Links: { Self: selfUrl(account, product) },
Meta: { TotalPages: 1 },
}
}
See the corresponding TPP API guide for how the TPP detects the JWE shape, forwards it to the user's device, and decrypts using the OTP the customer types in.
Reject the whole request when the limits are exceeded
Every call to GET /accounts/{AccountId}/product that produces an encrypted FinanceRates mints a fresh OTP and triggers a customer-facing message. The LFI MUST rate-limit these calls per consent per account so an abusive or buggy TPP cannot spam the customer.
The limits
| Rule | Limit | Behaviour when exceeded |
|---|---|---|
| Minimum interval between fresh OTPs | 60 seconds per (consent, account) pair | Reject the whole request with 429 Too Many Requests. The TPP surfaces a "please wait" message and lets the customer retry shortly. |
| Rolling 24-hour cap | 12 fresh OTPs per (consent, account) pair | Reject the whole request with 429 Too Many Requests until the rolling window admits a new call. Customer is told to try again later. |
| Re-decryption of an existing JWE | Not limited — decryption happens in the TPP browser and does not contact the LFI | n/a |
These limits are LFI-enforced and apply only to the FinanceRates-encryption path. Cleartext calls to GET /accounts/{AccountId}/product (for product types where the LFI does not encrypt) follow the standard rate limits documented elsewhere.
The 60-second minimum interval is the limit that matters most in practice: a delivered OTP can take a little while to reach the customer, so the interval simply stops a fresh code being minted before the previous one has had a chance to arrive. The rolling 24-hour cap leaves headroom for a TPP with a genuine reason to read a customer's finance rates several times through the day, and is kept under review as real-world usage patterns emerge. LFIs SHOULD treat it as a backstop against runaway message volume rather than a constraint legitimate traffic is expected to approach.
The counter is per (consent, account) pair, not per TPP and not per customer. A customer who holds multiple consents from different TPPs sees the limits applied independently per consent — one TPP's behaviour does not block another TPP's call. Within a single consent, a TPP that legitimately needs to read finance rates for several accounts gets a separate 24-hour budget per account.
The 429 response
When either limit would be breached, the LFI MUST reject the entire request rather than returning a partial response with FinanceRates omitted. The customer is on the TPP's screen expecting a complete product view; the 429 is the signal the TPP needs to render a clear, actionable error rather than a silently degraded payload.
The response uses the same 429Error envelope already defined for GET /accounts/{AccountId}/product in the OpenAPI specification — HTTP 429, no response body, two headers:
HTTP/1.1 429 Too Many Requests
Retry-After: 60
x-fapi-interaction-id: 7c9e6679-7425-40de-944b-e07fc1f90ae7
| Header | Required | Value |
|---|---|---|
Retry-After | MUST | Integer seconds until the next request would succeed. For the minimum-interval breach this is the seconds remaining in the 60-second cooldown; for the daily cap this is the seconds until the rolling window admits a new call. |
x-fapi-interaction-id | MUST | The same UUID the TPP sent on the request, echoed back so the TPP can correlate the rejection with its outbound call. |
The API Hub forwards the LFI's 429 response (status, Retry-After, and x-fapi-interaction-id) to the TPP without modification. There is no Hub-side translation step — the TPP receives the same response the LFI emitted. See the corresponding TPP API guide for the customer-facing handling on the other end of the pipe.
When the limit is breached, the LFI MUST NOT return 200 with the FinanceRates field omitted, an empty object, or a stale JWE. The 429 is the contract that lets the TPP distinguish "you don't have this permission" from "you've called too often" — collapsing the two would mislead the TPP about why the customer can't see the rate.
Logging, monitoring, and incident response
- No cleartext in logs — OTP, derived key material, and cleartext
FinanceRatesMUST be excluded from application logs, request traces, audit trails, monitoring dashboards, and crash dumps. - Audit the metadata, not the secret — the LFI SHOULD log the fact that an encrypted-rate response was issued (timestamp, consent ID, account ID, product ID, delivery channel) so abuse and operational issues are observable. The OTP and rate themselves MUST NOT be part of those records.
- Delivery monitoring — the LFI MUST monitor delivery success rates for whichever channels it uses to send the code. A sustained dip in delivery to encrypted-rate customers degrades the entire product silently from the customer's point of view.
- Treat the OTP like a password — on incident response, OTP exposure is reportable. There is no recovery path other than waiting for the 30-minute JWE window to close, so even a single leaked OTP costs at most one customer's encrypted rate.
