Security · FAPI · Callbacks

Handling Authorization Callbacks 2 min read

After the user approves (or declines) consent at the LFI, the Authorization Server redirects them back to your registered redirect_uri. How you handle this callback is security-critical — mistakes here can allow CSRF attacks, token theft, and authorization code replay.

01 Callback URL Format

What the Authorization Server sends back

The callback URL will be of the form:

Redirect URL
https://yourapp.com/callback?code=fbe03604-baf2-4220-b7dd-05b14de19e5c&state=d2fe5e2c-77cd-4788-b0ef-7cf0fc8a3e54&iss=https://auth1.altareq1.sandbox.apihub.openfinance.ae
ParameterDescription
codeThe authorization code to exchange at /token. Single-use and short-lived
stateThe value you sent in the Request JWT — must match what you stored before redirecting
issThe issuer of the Authorization Server that issued the code
02 Validate state

Always confirm the state value matches

Confirm that the state value returned in the callback matches the one you set in your Request JWT. This protects against CSRF (Cross-Site Request Forgery) attacks where a malicious page triggers an unintended authorization.

typescript
const params = new URLSearchParams(window.location.search)
// or server-side: new URLSearchParams(req.url.split('?')[1])

const state = params.get('state')!

if (state !== storedState) {
  // Do not proceed — discard the code and show an error
  throw new Error('State mismatch — possible CSRF attack')
}
03 Verify the iss (Issuer)

Confirm the response came from the expected Authorization Server

Check that the iss parameter matches the Authorization Server you sent the /par request to. This ensures the response comes from the expected LFI and not a confused deputy or misconfigured redirect.

typescript
const iss = params.get('iss')!

if (iss !== ISSUER) {
  throw new Error(`Unexpected issuer in callback: ${iss}`)
}
04 Time-Limit Callback Validity

Authorization codes are single-use and short-lived

Authorization codes are single-use and short-lived — typically valid for only a few minutes. Exchange the code immediately upon receipt.

  • Exchange the code at /token within seconds of receiving it — do not queue or defer
  • Do not accept callbacks that arrive long after the authorization request was initiated
  • Once a code has been exchanged successfully, treat it as consumed and reject any attempt to use it again
Track request initiation time

Store a timestamp when you send the user to /par. In your callback handler, reject any callback where too much time has elapsed since that timestamp (e.g. more than 10 minutes), even if state is otherwise valid.

05 Keep Callback Logic Minimal

Do only what's required, then redirect

When handling the callback, execute only the minimum necessary logic:

  1. Validate state and iss
  2. Exchange the code for tokens at /token
  3. Store tokens securely
  4. Redirect the user to the next step in your application flow

Avoid running complex business logic, sending external requests (other than /token), or initiating sensitive operations at this stage. A failed or slow callback should not leave the user in an inconsistent state.

Error handling

If validation fails or the code exchange returns an error, show the user a clean error message and discard all parameters from the callback. Do not log authorization codes or tokens.

06 Complete Callback Handler

End-to-end example combining every check

typescript
import { buildClientAssertion } from './client-assertion'

const ISSUER       = process.env.AUTHORIZATION_SERVER_ISSUER!
const REDIRECT_URI = process.env.REDIRECT_URI!

// Token endpoint is read from .well-known/openid-configuration —
// not constructed from the issuer URL (it lives on a different host).
const TOKEN_ENDPOINT = discoveryDoc.token_endpoint

export async function handleCallback(callbackUrl: string, session: {
  storedState: string
  codeVerifier: string
}) {
  const params = new URLSearchParams(callbackUrl.split('?')[1])

  const code  = params.get('code')
  const state = params.get('state')
  const iss   = params.get('iss')

  // 1. Validate state
  if (!state || state !== session.storedState) {
    throw new Error('State mismatch — possible CSRF attack')
  }

  // 2. Validate issuer
  if (!iss || iss !== ISSUER) {
    throw new Error(`Unexpected issuer: ${iss}`)
  }

  if (!code) {
    throw new Error('No authorization code in callback')
  }

  // 3. Exchange code for tokens immediately
  const tokenResponse = await fetch(TOKEN_ENDPOINT, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type:            'authorization_code',
      code,
      redirect_uri:          REDIRECT_URI,
      code_verifier:         session.codeVerifier,
      client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
      client_assertion:      await buildClientAssertion(),
    }),
    // agent: new https.Agent({ cert: transportCert, key: transportKey }),
  })

  if (!tokenResponse.ok) {
    throw new Error(`Token exchange failed: ${tokenResponse.status}`)
  }

  const { access_token, refresh_token, expires_in } = await tokenResponse.json()

  // 4. Return tokens — caller is responsible for secure storage
  return { access_token, refresh_token, expires_in }
}