How to Decrypt PII 5 min read
The PersonalIdentifiableInformation field is a compact JWE (JSON Web Encryption) string. It was encrypted by the TPP using your LFI's public encryption key (Enc1). To decrypt it, you need the corresponding Enc1 private key.
Two decryption patterns — decide which one you are building
The same PII reaches your Ozone Connect endpoints at more than one point in the consent lifecycle. The bytes are identical every time: the TPP signed and encrypted them once, at PAR. What differs is how much time has passed since — and that decides which keys you need and how the timing claims behave.
| Where the PII reaches you | Age of the PII | Pattern |
|---|---|---|
POST /consent/action/validate | Seconds | A — at creation |
POST /consent/event/post | Seconds | A — at creation |
| The consent authorisation journey | Minutes to hours | B — later |
POST /consent/event/patch | Minutes to hours | B — later |
POST /payments | Up to the consent lifetime | B — later |
Pattern A — decrypt at consent creation
/consent/action/validate is called by the API Hub synchronously, inside the TPP’s PAR request and before the consent exists. The PII is at most seconds old, and the post consent event fires at the same moment in the lifecycle. Nothing can have rotated in between, so:
- The
kidin the JWE header names an Enc1 key you currently hold. - If you verify the TPP’s signature, the TPP’s active JWKS is sufficient.
- The JWS
exp,iatandnbfclaims can be evaluated against the current time, because now is the moment of signing. Library defaults are correct here.
Pattern B — decrypt later
Everywhere else is “later”. The authorisation journey already is: between the TPP creating the consent and the customer authenticating on your platform there can be a redirect the customer does not follow immediately, an app switch, an abandoned login resumed later. Payment execution is later still — a multi-payment consent executes against the same PII for as long as the consent lives. Three things follow.
- Your own Enc1 key. The JWE was encrypted to whichever Enc1 public key you had published when the consent was created. The recommended Server ENCKEY certificate type does not expire, so routine rotation is avoided — but if you do replace Enc1, you MUST retain the retired private key for as long as consents encrypted under it remain in force. Step 1 below exists for exactly this reason: resolve the private key from the
kid, never from “the current one”. - The TPP’s signing key — only if you verify the signature. TPPs rotate signing keys freely and without notice, and a rotated
kidleaves the active JWKS. Resolve thekidagainst the active set and then against theinactive/set, and pinPS256yourself — published JWKS entries carry noalg. - The timing claims.
exp,iatandnbfon the PII JWS bound the PAR submission window — the moment the TPP created the consent — not the moment you are decrypting. On a long-lived consent they will normally have lapsed. Evaluate them against the consent’sCreationDateTime, or do not evaluate them at all. Whether the consent is still usable today is answered by the API Hub’s consent validation on every request, not by a claim inside the PII.
Pattern B is also correct at consent creation — the active JWKS simply hits every time, and the timing claims happen to be current. Pattern A is not correct anywhere else, and it fails in a way that is hard to diagnose: it works for months, then starts rejecting perfectly valid PII on older consents, with no change at either end. If you decrypt at more than one point in the lifecycle, build Pattern B once and use it everywhere.
Verifying the PII Signature — Rotated Keys and the Inactive JWKS covers the signing-key half of Pattern B in full: the active and inactive/ keystore URL pair, the lookup order, and what membership of the inactive set does and does not tell you.
Read the kid from the JWE header
The JWE protected header contains the kid (Key ID) of the encryption key that was used. Decode the first segment of the JWE to identify which private key to use — under Pattern B this may be a retired Enc1 key rather than your current one, so always resolve by kid:
function getJweKid(jweString: string): string {
const [headerB64] = jweString.split('.')
const header = JSON.parse(Buffer.from(headerB64, 'base64url').toString())
return header.kid
}
const kid = getJweKid(piiJweString)
const privateKey = myKeyStore.getPrivateKey(kid)
Decrypt the JWE
Decrypt the JWE using your Enc1 private key. The result is the inner JWS (signed JWT):
import { compactDecrypt, importPKCS8 } from 'jose'
const privateKeyPem = myKeyStore.getPrivateKeyPem(kid)
const privateKey = await importPKCS8(privateKeyPem, 'RSA-OAEP-256')
const { plaintext } = await compactDecrypt(piiJweString, privateKey)
const jwsString = new TextDecoder().decode(plaintext)
Decode the JWS payload
The inner JWS contains the PII JSON in its payload. Decode the payload to access the PII fields:
import { decodeJwt } from 'jose'
const piiPayload = decodeJwt(jwsString)
// piiPayload now contains { Initiation: { ... }, Risk: { ... }, iat, exp, iss, ... }
The JWS is signed by the TPP. You may optionally verify this signature against the TPP's public signing key. However, this is not required — the entire request containing the PII field is itself sent as a JWS that the API Hub has already verified was signed by the TPP. The PII therefore cannot have been tampered with in transit.
If you choose to implement JWS verification for defence-in-depth, see Verify TPP Signature (Optional). Under Pattern B you must also resolve the signing kid against the TPP’s inactive/ JWKS, and stop your library checking exp, iat and nbf against the current time — see Verifying the PII Signature.
Validate the PII against the OpenAPI schema
After decrypting, the LFI MUST validate the PII payload against the relevant OpenAPI schema. The PII has not been validated by the API Hub — schema validation is the LFI's responsibility.
| Stage | Spec file | Schema |
|---|---|---|
| Consent | uae-api-hub-consent-manager-openapi.yaml | AEBankServiceInitiationRichAuthorizationRequests.AEDomesticPaymentPII |
| Payment | uae-ozone-connect-bank-service-initiation-openapi.yaml | AEBankServiceInitiation.AEDomesticPaymentPIIProperties |
Obtaining the OpenAPI specification
The OpenAPI YAML files are the source of truth for PII schemas. They are maintained in the canonical specification repository:
Spec files are located under dist/ by category:
| Stage | Path |
|---|---|
| Consent | dist/api-hub/{version}/openapi/uae-api-hub-consent-manager-openapi.yaml |
| Payment | dist/ozone-connect/{version}/openapi/uae-ozone-connect-bank-service-initiation-openapi.yaml |
Specifications may have errata releases (e.g. v2.1.x-errata1) that contain targeted corrections. When multiple version folders exist for the same major.minor version, use the highest errata that contains the file you need. If a file is not present in an errata folder, fall back to the base version. Always check for errata before bundling a spec into your service.
Validating against the schema
Extract the relevant components/schemas entry from the YAML file and validate the decrypted PII payload against it. The PII schemas in the OpenAPI specification already declare the constraints needed for validation:
additionalProperties: falseis set at every level of the PII schema — any unexpected fields will cause validation to fail.requiredarrays are declared on sub-schemas (e.g.CreditorAccountis required on each creditor entry,SchemeNameandIdentificationare required on account objects) — missing mandatory fields will cause validation to fail.enumconstraints restrict values to allowed options (e.g.SchemeNamemust beIBAN).$refpointers link to nested schemas (creditor, debtor, risk). For validation to work correctly, allcomponents/schemasentries from the spec MUST be registered with the validator so that$refpointers resolve.
When you register the full set of component schemas and compile the PII schema, standard JSON Schema validators (ajv for Node.js, jsonschema for Python) will enforce all of these constraints automatically. No custom validation logic is needed for schema conformance — the OpenAPI spec is the single source of truth.
The following example shows how to validate a domestic payment PII at consent time:
import Ajv from 'ajv'
import { load } from 'js-yaml'
import { readFileSync } from 'fs'
// 1. Load the OpenAPI spec and extract the PII schema
const spec = load(
readFileSync('uae-api-hub-consent-manager-openapi.yaml', 'utf-8')
) as Record<string, any>
const piiSchema =
spec.components.schemas[
'AEBankServiceInitiationRichAuthorizationRequests.AEDomesticPaymentPII'
]
// 2. Build a validator — register all component schemas so $ref resolves
const ajv = new Ajv({ allErrors: true, strict: false })
for (const [name, schema] of Object.entries(spec.components.schemas)) {
ajv.addSchema(schema as object, `#/components/schemas/${name}`)
}
const validate = ajv.compile(piiSchema)
// 3. Validate the decrypted PII payload
function validatePIISchema(piiPayload: Record<string, unknown>): void {
const valid = validate(piiPayload)
if (!valid) {
const errors = validate.errors?.map(e => `${e.instancePath} ${e.message}`)
throw new Error(`PII schema validation failed:\n${errors?.join('\n')}`)
}
}
If the decrypted PII fails schema validation, the LFI MUST reject the consent or payment. Do not attempt to process a payment with malformed PII — return an appropriate error response. See Personal Identifiable Information for the full set of validation rules.
Decryption and validation, end-to-end
This example resolves the Enc1 private key from the JWE kid, so it is correct under both patterns. It does not verify the TPP’s signature; if you add that step, follow Pattern B unless you are certain the code path only ever runs at consent creation.
import { compactDecrypt, importPKCS8, decodeJwt } from 'jose'
async function decryptAndValidatePII(
piiJweString: string,
kid: string
): Promise<Record<string, unknown>> {
// 1. Load the Enc1 private key matching the kid
const privateKeyPem = myKeyStore.getPrivateKeyPem(kid)
const privateKey = await importPKCS8(privateKeyPem, 'RSA-OAEP-256')
// 2. Decrypt the JWE → inner JWS
const { plaintext } = await compactDecrypt(piiJweString, privateKey)
const jwsString = new TextDecoder().decode(plaintext)
// 3. Decode the JWS payload (signature verification is optional — see note above)
const piiPayload = decodeJwt(jwsString)
// 4. Validate against the OpenAPI schema
validatePIISchema(piiPayload)
return piiPayload
}
