LFI · Banking · Service Initiation · PII · API Guide

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.

Before you start

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 youAge of the PIIPattern
POST /consent/action/validateSecondsA — at creation
POST /consent/event/postSecondsA — at creation
The consent authorisation journeyMinutes to hoursB — later
POST /consent/event/patchMinutes to hoursB — later
POST /paymentsUp to the consent lifetimeB — 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 kid in 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, iat and nbf claims 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 kid leaves the active JWKS. Resolve the kid against the active set and then against the inactive/ set, and pin PS256 yourself — published JWKS entries carry no alg.
  • The timing claims. exp, iat and nbf on 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’s CreationDateTime, 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 the safe default

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.

Key rotation in depth

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.

01 Step 1

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:

typescript
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)
02 Step 2

Decrypt the JWE

Decrypt the JWE using your Enc1 private key. The result is the inner JWS (signed JWT):

typescript
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)
03 Step 3

Decode the JWS payload

The inner JWS contains the PII JSON in its payload. Decode the payload to access the PII fields:

typescript
import { decodeJwt } from 'jose'

const piiPayload = decodeJwt(jwsString)
// piiPayload now contains { Initiation: { ... }, Risk: { ... }, iat, exp, iss, ... }
Optional — Verify the TPP's JWS signature

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.

04 Step 4

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.

StageSpec fileSchema
Consentuae-api-hub-consent-manager-openapi.yamlAEBankServiceInitiationRichAuthorizationRequests.AEDomesticPaymentPII
Paymentuae-ozone-connect-bank-service-initiation-openapi.yamlAEBankServiceInitiation.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:

Nebras-Open-Finance/api-specs

Spec files are located under dist/ by category:

StagePath
Consentdist/api-hub/{version}/openapi/uae-api-hub-consent-manager-openapi.yaml
Paymentdist/ozone-connect/{version}/openapi/uae-ozone-connect-bank-service-initiation-openapi.yaml
Errata versions

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: false is set at every level of the PII schema — any unexpected fields will cause validation to fail.
  • required arrays are declared on sub-schemas (e.g. CreditorAccount is required on each creditor entry, SchemeName and Identification are required on account objects) — missing mandatory fields will cause validation to fail.
  • enum constraints restrict values to allowed options (e.g. SchemeName must be IBAN).
  • $ref pointers link to nested schemas (creditor, debtor, risk). For validation to work correctly, all components/schemas entries from the spec MUST be registered with the validator so that $ref pointers 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:

typescript
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')}`)
  }
}
Reject invalid PII

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.

05 Full example

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.

typescript
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
}