Security · FAPI · /par

Preparing the Request JWT 2 min read

To send a /par request, you must first construct a signed Request JWT (also called a Request Object or JAR — JWT Authorization Request). This JWT is a signed package of all authorization parameters, proving they came from your registered application and haven't been tampered with.

Request JWT highlighted in the PAR flow
Request JWT highlighted in the PAR flow
Strict claim rules

For a precise per-claim reference covering aud, exp/nbf lifetime windows, clock skew, and the difference between the Request Object and Client Assertion, see JWT Claim Rules.

02 Payload Claims

Authorization parameters carried inside the signed JWT

ClaimTypeRequiredDescriptionExample
audstringThe issuer of the Authorization Server — found via API Discoveryhttps://auth1.[LFICode].apihub.openfinance.ae
iatnumberIssued At Unix timestamp — when the JWT was created1713196113
expnumberExpiry as a Unix timestamp. Must be shortly after nbf — maximum 5 minutes1713196423
issstringYour application's Client ID from the Trust Frameworkyour-client-id
client_idstringYour application's Client ID (same as iss)your-client-id
redirect_uristringThe callback URI registered in your Trust Framework applicationhttps://yourapp.com/callback
scopestringSpace-separated OAuth 2.0 scopesaccounts openid
noncestringRandom UUID — prevents replay attacks by binding the ID token to this requesta1b2c3d4-...
statestringRandom UUID — returned in the redirect; prevents CSRF attackse5f6g7h8-...
nbfnumberNot Before Unix timestamp. Set slightly before iat (e.g. iat - 10) to allow for clock skew1713196103
response_typestringMust be codecode
code_challenge_methodstringPKCE method — only S256 is supportedS256
code_challengestringBase64url-encoded SHA-256 hash of your code_verifierE9Melhoa2Ow...
max_agenumberMaximum age (seconds) of the user's existing authentication session. Capped at 36003600
authorization_detailsarrayDescribes what the user is consenting to — see Consent[{...}]
03 PKCE

Generating a PKCE code challenge

Before building the JWT, generate a code_verifier and derive the code_challenge from it:

typescript
import crypto from 'node:crypto'

// Generate a cryptographically random code_verifier (43–128 chars, URL-safe)
export function generateCodeVerifier(): string {
  return crypto.randomBytes(32).toString('base64url')
}

// Derive the code_challenge (S256 = SHA-256 of the verifier, base64url-encoded)
export function deriveCodeChallenge(verifier: string): string {
  return crypto.createHash('sha256').update(verifier).digest('base64url')
}

Store the code_verifier securely — you'll need it when exchanging the authorization code for tokens.

04 Building the Request JWT

Assemble and sign the JWT in code

typescript
import { SignJWT, importPKCS8 } from 'jose'
import { readFileSync } from 'node:fs'
import crypto from 'node:crypto'

const ALGORITHM   = 'PS256'
const KEY_ID      = process.env.SIGNING_KEY_ID!
const CLIENT_ID   = process.env.CLIENT_ID!
const ISSUER      = process.env.AUTHORIZATION_SERVER_ISSUER!  // from .well-known
const REDIRECT_URI = process.env.REDIRECT_URI!

const privateKey = await importPKCS8(
  readFileSync('./certificates/signing.key', 'utf8'),
  ALGORITHM
)

interface RequestJWTOptions {
  scope: string
  codeChallenge: string
  authorizationDetails: unknown[]
  maxAge?: number
}

export async function buildRequestJWT({
  scope,
  codeChallenge,
  authorizationDetails,
  maxAge = 3600,
}: RequestJWTOptions): Promise<string> {
  const now = Math.floor(Date.now() / 1000)

  return new SignJWT({
    // Authorization Server identity
    aud: ISSUER,

    // Client identity
    iss: CLIENT_ID,
    client_id: CLIENT_ID,

    // Authorization parameters
    scope,
    redirect_uri: REDIRECT_URI,
    response_type: 'code',

    // PKCE
    code_challenge_method: 'S256',
    code_challenge: codeChallenge,

    // Security
    nonce: crypto.randomUUID(),
    state: crypto.randomUUID(),
    max_age: maxAge,

    // Consent
    authorization_details: authorizationDetails,

    // Timing
    iat: now,
    nbf: now - 10,
    exp: now + 300,  // 5-minute expiry
  })
    .setProtectedHeader({ alg: ALGORITHM, kid: KEY_ID })
    .sign(privateKey)
}
05 Full Example

End-to-end PAR submission and redirect

typescript
import { generateCodeVerifier, deriveCodeChallenge } from './pkce'
import { buildRequestJWT } from './request-jwt'

// 1. Generate PKCE pair
const codeVerifier  = generateCodeVerifier()
const codeChallenge = deriveCodeChallenge(codeVerifier)

// 2. Build the authorization_details (example: bank data sharing consent)
const authorizationDetails = [
  {
    type: 'urn:openfinanceuae:account-access-consent:v2.1',
    consent: {
      ConsentId: crypto.randomUUID(),
      ExpirationDateTime: new Date(Date.now() + 364 * 24 * 60 * 60 * 1000).toISOString(),
      Permissions: ['ReadAccountsBasic', 'ReadBalances', 'ReadTransactionsBasic'],
      OpenFinanceBilling: {
        UserType: 'Retail',
        Purpose: 'AccountAggregation',
      },
    },
  },
]

// 3. Build and sign the Request JWT
const requestJWT = await buildRequestJWT({
  scope: 'accounts openid',
  codeChallenge,
  authorizationDetails,
})

// 4. Send to /par
// Endpoints are read from .well-known/openid-configuration —
// not constructed from the issuer URL (they live on different hosts).
const response = await fetch(discoveryDoc.pushed_authorization_request_endpoint, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/x-www-form-urlencoded',
  },
  body: new URLSearchParams({ request: requestJWT }),
})
const { request_uri, expires_in } = await response.json()

// 5. Redirect the user
const authorizeUrl = new URL(discoveryDoc.authorization_endpoint)
authorizeUrl.searchParams.set('client_id', CLIENT_ID)
authorizeUrl.searchParams.set('request_uri', request_uri)
window.location.href = authorizeUrl.toString()
Tip

Store codeVerifier in your session — you'll need it at the /token endpoint to exchange the authorization code for access tokens.

06 Encrypting Payment PII

The pii field inside payment consents is itself a JWE

For payment consents, the consent's PII payload (creditor and debtor names, account numbers, and related personal data) must be encrypted with the LFI's public encryption key and carried as a JWE in the consent's pii field — nested inside authorization_details within this Request JWT. The Request JWT itself is signed (JWS) but is not wrapped in a JWE; only the pii field is.

This keeps the PII end-to-end encrypted: the API Hub routes the consent without being able to read the personal data, and only the LFI — holder of the private key — can decrypt and validate it. See Message Encryption for how to produce the JWE, and Payment PII Encryption for the rationale and validation responsibilities.