Callback

Receive the form_post relay, validate state and iss, and exchange the code on your server.

Die Partnerdokumentation wird auf Englisch veröffentlicht.

#Relay fields

On consent the user's browser submits a cross-origin POST with Content-Type: application/x-www-form-urlencoded to your registered redirect URI. It is OAuth's response_mode=form_post, extended with the zero-knowledge key relay:

FieldContents
codeone-time authorization code, valid five minutes
stateyour state, echoed when you sent one
issthe issuer (RFC 9207); compare with discovery
privateKeythe user's base64 PKCS#8 private key
publicKeythe matching public key; may be empty

The same list, in this order, is published in discovery as open_banking_io.key_relay.fields and is pinned by tests on both sides.

Your callback must accept POST; a GET never arrives. Two framework defaults break it: a CSRF middleware that rejects cross-site POSTs without your own token (Rails, Django, Laravel, csurf) must exempt this route, and the body parser must accept application/x-www-form-urlencoded (Express: express.urlencoded({ extended: false }) on the route). publicKey is informational and may be empty on older devices; nothing needs it. It can be reached from a browser context that does not carry your session cookie (below), so validate with the state you were handed, not with a cookie.

#The callback, whole

One handler does everything: consume the flow by state, branch on error, validate iss, exchange the code on your server, store the bundle, and finish the page for the mode the flow started in. With the Node client 1.1.0:

js

import express from 'express';
import { discover, exchangeCode, parseRelay, RelayError, OpenBankingClient } from '@open-banking-io/client';

const { ISSUER, CLIENT_ID, CLIENT_SECRET, SELF_URL } = process.env;

const closePage = (outcome) => `<!doctype html>
<p>${outcome === 'connected' ? 'Connected — you can close this window.' : 'Cancelled.'}</p>
<script>
  try { new BroadcastChannel('bank-connect').postMessage(${JSON.stringify(outcome)}); } catch {}
  setTimeout(() => window.close(), 300);
</script>`;

const finish = (res, flow, outcome) =>
  flow.mode === 'redirect' ? res.redirect(302, `/?connect=${outcome}`) : res.send(closePage(outcome));

app.post('/callback', express.urlencoded({ extended: false }), async (req, res, next) => {
  try {
    const flow = await flows.take(req.body.state);
    if (!flow || flow.expiresAt < Date.now()) return res.status(400).send('unknown or expired state');

    const { issuer } = await discover(ISSUER);
    let relay;
    try {
      relay = parseRelay(req.body, { expectedState: flow.state, issuer });
    } catch (e) {
      if (e instanceof RelayError && e.code === 'access_denied') return finish(res, flow, 'cancelled');
      throw e;
    }

    const token = await exchangeCode({
      issuer,
      clientId: CLIENT_ID,
      clientSecret: CLIENT_SECRET,
      code: relay.code,
      codeVerifier: flow.verifier,
      redirectUri: `${SELF_URL}/callback`,
    });
    await bundles.put(flow.sessionId, { token, privateKey: relay.privateKey });
    finish(res, flow, 'connected');
  } catch (e) {
    next(e);
  }
});
  • flows.take(state) binds the POST to the session that started the flow and consumes it; a second POST with the same state finds nothing. Do this before branching on error, so a cancel consumes the flow too.
  • parseRelay compares state and iss in constant time and throws a typed RelayError; access_denied is the user pressing "Back to {your name}" or declining — a normal outcome, not a failure. Any other error is.
  • exchangeCode posts the form-encoded token request with HTTP Basic client authentication and a 30-second timeout, and throws an OAuthError carrying the RFC 6749 error. Never exchange from the browser; never retry an invalid_grant — the code is consumed before the verifier is checked, so a wrong verifier burns it. Every response and error is in tokens and keys.
  • bundles is your store of { token, privateKey } per user session — encrypted at rest or memory-only — and OpenBankingClient.fromTokenResponse(token, privateKey) reads with exactly that pair. Never take the redirect target, the issuer or the token endpoint from the request.

Without the client, the exchange is POST {issuer}/oauth/token with Content-Type: application/x-www-form-urlencoded, grant_type=authorization_code&code=…&code_verifier=…&redirect_uri=… and Authorization: Basic base64(client_id:client_secret); the body comes back with access_token, apiKey (the same key), apiBaseUrl and user, and errors as { "error", "error_description" }.

#Finishing the page

finish above answers a redirect-mode flow with a 302 to your own page (the flow's sessionId tells you whose) and a popup-mode flow with a small page that signals your opener and closes itself — window.opener is severed (below), so it uses a BroadcastChannel (any name; it is private to your origin) and your page polls your own status endpoint as the source of truth:

js

app.get('/api/status', async (req, res) => {
  const b = await bundles.get(req.session.id);
  if (!b) return res.json({ connected: false });
  const client = OpenBankingClient.fromTokenResponse(b.token, b.privateKey);
  const accounts = await client.getAccounts();
  res.json({ connected: true, syncing: accounts.length === 0, accounts: accounts.length });
});

A 401 from any read means the key is gone (revoked or expired): drop the bundle and offer to connect again. Until the Node client throws typed errors, that is the status on the thrown error's message.

Your page opens the channel before it opens the popup, and on any message (or on a timer) calls /api/status until connected is true and syncing is false.

#Your session cookie is not sent on the relay POST

A cross-site POST navigation does not carry SameSite=Lax cookies (Lax covers top-level navigation with safe methods only), and Strict is worse. The failure is confusing: code and privateKey arrive fine, but the session holding your state looks empty, so your own check rejects a perfectly good authorization.

Two fixes, in increasing order of robustness:

  1. SameSite=None; Secure on the session cookie. One line; now dependent on third-party-cookie behaviour, which browsers keep tightening.
  2. Do not need the cookie at all (recommended, and what the examples above do): make state a random key into a short-lived server-side record holding the verifier and the id of the session that started the flow. The callback looks everything up by state, attaches the resulting bundle to that session, and never reads a cookie. Your page still polls your own origin — a same-origin GET, where Lax is fine.

#The popup cannot talk to your page

open-banking.io sends Cross-Origin-Opener-Policy: same-origin. The moment the popup navigates there its window.opener is severed for good, so it cannot postMessage your page. A naive integration reads that silence as failure and shows an error on success.

Detect the outcome from shared server state: the callback marks the flow connected, your page polls your own endpoint until it flips. Add a same-origin BroadcastChannel nudge from the close page for an instant response, and keep window.opener?.postMessage only as a harmless fallback.

If your close page renders a payload for a script to read, emit the payload before the script tag, or the script reads null and defaults to an error on the success path.

#Cache skew after a deploy

Serve your popup's static assets fingerprinted (app.js?v=<hash>) with the HTML no-store. Unversioned assets behind a CDN make a returning browser run yesterday's JavaScript against today's HTML — the classic "popup fails, manual refresh works" report.

#Sandbox

Register a client on https://staging.open-banking.io, use its discovery document, and connect Mock ASPSP in the sandbox; no real bank is involved. The reference implementation at partner-demo.open-banking.io runs against exactly this; its source is at github.com/open-banking-io/partner-demo.