Learn · Understand · Build

Emirates ID in login_hint — Sending It as a TPP, Receiving It as an LFI

CategorySecurityRead7 minUpdated22 Oct 2026

login_hint is the one place in the authorization request built to carry a PSU identifier. It is not free text: the standards define a two-property structure for an Emirates ID or Trade Licence Number, encrypted to the LFI's ENC1 key so that only the LFI can read it. It reaches the LFI exactly once, in the GET /auth response — it is never stored on the consent.

login_hintEmirates IDJWE
01 What it is

A hint about identity, sent before the PSU arrives

When a TPP creates a consent it knows who its customer is, but the LFI does not find out until the PSU authenticates. login_hint closes that gap: the TPP passes the PSU's Emirates ID or Trade Licence Number in the Request JWT, and the LFI receives it when it starts the authorization interaction — before it has to render anything to the PSU.

As the standards put it, this exists “to allow LFIs to correlate the User identity, and therefore the features of their account, prior to the User being redirected to the LFI's authentication page.” An LFI can use it to pre-select a customer segment, choose an authentication channel, or decide which products the journey should offer.

A hint, and nothing more

login_hint is used for discovery only. It MUST NOT be used to bypass authentication of the PSU.

StageWhoWhat happens to the hint
POST /parTPP → API HubSent as the login_hint claim inside the signed Request JWT, already encrypted to the LFI
PSU redirectTPP → PSU → LFINot present — the redirect carries only client_id and request_uri
GET /authLFI → API HubReturned on interaction.params.login_hint, byte-for-byte as the TPP sent it
GET /consents/{consentId}LFI → API HubNot present — the hint is never written onto the consent
02 The payload

Two properties, and only two

The decoded payload is defined by the AESecurityProfile.AELoginHintJwePayload schema in the Authorization Endpoints specification:

json
{
  "Type": "EmiratesID",
  "Identification": "784-1985-7654321-9"
}
PropertyRequiredValue
TypeYesEither EmiratesID or TradeLicenseNumber — nothing else is permitted
IdentificationYesThe identification number itself
The schema is closed

AELoginHintJwePayload sets additionalProperties: false. The object carries these two properties and no others — do not add JWT registered claims such as iss, aud or exp to the payload itself. This is a deliberate difference from payment PII, whose signed payload does carry them.

On the wire the value is never that JSON object. It MUST be a JWE: the object is signed with the TPP's signing key to produce a JWS, and that compact JWS is then encrypted to the LFI's public encryption key. The schema is shown in decoded form in the specification only because OpenAPI cannot express the structure inside a JWT.

03 For TPPs

Sign it, encrypt it to the LFI, send it in the Request JWT

The mechanics are the same as payment PII — a JWS wrapped in a JWE, encrypted to the LFI's ENC1 key. Only the destination claim differs.

Step 1 — Find the LFI's encryption key

The hint is encrypted to the target LFI, not to the API Hub. Fetch the LFI's JWKS from the Directory keystore using that LFI's organisation ID, and take the key with use: "enc":

text
Production   https://keystore.directory.openfinance.ae/{LFI-organisation-id}/application.jwks
Sandbox      https://keystore.sandbox.directory.openfinance.ae/{LFI-organisation-id}/application.jwks

That is the LFI's ENC1 key, the same server-side encryption key used for payment PII and described in Keys & Certificates. Use the kid from the JWK you selected.

Step 2 — Build the JWE

Sign first, then encrypt — the JWE wraps a JWS. Use RSA-OAEP-256 for key wrapping and A256GCM for content encryption, matching Message Encryption (JWE), and set cty: "JWT" so the LFI knows the plaintext is itself a token:

json
{
  "alg": "RSA-OAEP-256",
  "enc": "A256GCM",
  "kid": "<lfi-enc1-key-id>",
  "cty": "JWT"
}
typescript
import { SignJWT, CompactEncrypt, importPKCS8, importJWK } from 'jose'

/**
 * Build a login_hint value: a JWS signed with your signing key, wrapped in a
 * JWE encrypted to the LFI's ENC1 public key.
 *
 * @param identification  The Emirates ID or Trade Licence Number
 * @param type            'EmiratesID' | 'TradeLicenseNumber'
 * @param lfiJwksUri      The LFI's JWKS in the Directory keystore
 */
export async function buildLoginHint(
  identification: string,
  type: 'EmiratesID' | 'TradeLicenseNumber',
  lfiJwksUri: string,
): Promise<string> {
  // 1. The payload — exactly these two properties, nothing else
  const payload = { Type: type, Identification: identification }

  // 2. Sign it with your signing key (PS256)
  const signingKey = await importPKCS8(mySigningKeyPem, 'PS256')
  const jws = await new SignJWT(payload)
    .setProtectedHeader({ alg: 'PS256', typ: 'JWT', kid: mySigningKid })
    .sign(signingKey)

  // 3. Fetch the LFI's encryption key
  const { keys } = await (await fetch(lfiJwksUri)).json()
  const encJwk = keys.find((k: { use?: string }) => k.use === 'enc')
  if (!encJwk) throw new Error(`No encryption key (use: enc) in ${lfiJwksUri}`)
  const encKey = await importJWK(encJwk, 'RSA-OAEP-256')

  // 4. Encrypt the compact JWS — the JWE wraps a JWS, so cty is JWT
  return new CompactEncrypt(new TextEncoder().encode(jws))
    .setProtectedHeader({
      alg: 'RSA-OAEP-256',
      enc: 'A256GCM',
      kid: encJwk.kid,
      cty: 'JWT',
    })
    .encrypt(encKey)
}

Step 3 — Put it in the Request JWT

login_hint is a claim of the signed Request JWT you push to POST/par, alongside authorization_details. It is not a separate form field on the PAR request, and not a query parameter on the redirect:

json
{
  ....
  "login_hint": "eyJhbGciOiJSU0EtT0FFUC0yNTYiLCJlbmMiOiJBMjU2R0NNIiwia2lkIjoiPGxmaS1lbmMxLWtpZD4iLCJjdHkiOiJKV1QifQ...",
  "authorization_details": [
    {
      "type": "urn:openfinanceuae:account-access-consent:v2.1",
      "consent": { "...": "..." }
    }
  ]
}
A 201 from /par says nothing about the hint

The API Hub routes the encrypted value through to the LFI without reading it — it holds no key that could. A successful /par response therefore does not confirm that the LFI could decrypt your hint, or that you encrypted it to the right key. As with payment PII, test this end to end with each LFI you onboard with.

04 For LFIs

It arrives on the GET /auth response

You receive the hint from the API Hub when you start the authorization interaction — on interaction.params.login_hint, as the TPP encrypted it.

When the PSU lands on your Authorization Endpoint you forward the query parameters you received to GET/auth — step 5 of the Consent Journey. The response unbundles the Request JWT the TPP signed, and login_hint appears among those parameters:

json
{
  "interaction": {
    "interactionId": "d47f30f4-b238-4008-b151-2a6eaf9ab919",
    "params": {
      "client_id": "https://rp.directory.openfinance.ae/openid_relying_party/{software-statement-id}",
      "scope": "accounts openid",
      "code_challenge_method": "S256",
      "code_challenge": "F3338UWkgr_RTk4ppE6pDkE9f1mFXMAXx06gcptIhpM",
      "login_hint": "eyJhbGciOiJSU0EtT0FFUC0yNTYiLCJlbmMiOiJBMjU2R0NNIiwia2lkIjoiPGxmaS1lbmMxLWtpZD4iLCJjdHkiOiJKV1QifQ...",
      "authorization_details": [ { "...": "..." } ]
    },
    "status": "started",
    "consentIdsList": ["10ef5d6d-36b6-4e05-82d4-21df33199443"]
  },
  "tpp": { "...": "..." }
}

The value is the TPP's JWE, unchanged. Read the kid from its header, decrypt with the matching ENC1 private key, then decode the inner JWS to reach the two properties:

typescript
import { compactDecrypt, importPKCS8, decodeJwt } from 'jose'

interface LoginHint {
  Type: 'EmiratesID' | 'TradeLicenseNumber'
  Identification: string
}

/**
 * Read the login_hint from a GET /auth response. Returns null when the TPP
 * did not send one — which is permitted, and MUST NOT fail the interaction.
 */
export async function readLoginHint(
  authResponse: { interaction: { params: Record<string, unknown> } },
): Promise<LoginHint | null> {
  const jwe = authResponse.interaction.params['login_hint']
  if (typeof jwe !== 'string' || jwe === '') return null

  // 1. The kid in the JWE header tells you which ENC1 key to use
  const header = JSON.parse(
    Buffer.from(jwe.split('.')[0], 'base64url').toString(),
  )
  const privateKeyPem = myKeyStore.getPrivateKeyPem(header.kid)
  const privateKey = await importPKCS8(privateKeyPem, 'RSA-OAEP-256')

  // 2. Decrypt the JWE → the inner JWS
  const { plaintext } = await compactDecrypt(jwe, privateKey)
  const jws = new TextDecoder().decode(plaintext)

  // 3. Decode the JWS payload
  const { Type, Identification } = decodeJwt(jws) as unknown as LoginHint
  return { Type, Identification }
}
05 One delivery only

The hint is interaction state, not consent state

login_hint belongs to the authorization request. It is not part of the consent, and reading the consent back will not return it.

The consent stored by the API Hub holds what the TPP asked for — ConsentId, Permissions, ExpirationDateTime, OpenFinanceBilling and the consent status. The PSU identity hint is not among those fields, and GET/consents/{consentId} does not return it, at any point in the consent's life.

Capture it at GET /auth or lose it

GET /auth is the only place the hint is delivered. If your authorization journey needs the identifier in a later step — a downstream service, an audit record, a decision taken after the PSU authenticates — read it at /auth and carry it in your own interaction state, keyed by interactionId. There is no second opportunity to fetch it from the API Hub.