Callback
Receive the form_post relay, validate state and iss, and exchange the code on your server.
Partnerdokumentasjonen publiseres på engelsk.
#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:
| Field | Contents |
|---|---|
code | one-time authorization code, valid five minutes |
state | your state, echoed when you sent one |
iss | the issuer (RFC 9207); compare with discovery |
privateKey | the user's base64 PKCS#8 private key |
publicKey | the 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.
If you registered your own decryption key (see Tokens and keys), privateKey and publicKey arrive empty for users who connect after you registered it — their data is encrypted to the key you hold, and they are never asked for a passphrase. Users who connected BEFORE keep the key their browser made and keep relaying it, so handle both: a key in the relay is that user's, an empty one means yours. Everything else on this page is unchanged. Node client 1.1.0's parseRelay rejects an empty privateKey with missing_private_key — it checks the key last, after state, iss, the error branch and code, so a key-mode integration can catch exactly that code and carry on with its own key.
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 send the browser back to your own page with the outcome. With the Node client 1.1.0:
flows.take(state)consumes the flow: a second POST with the samestatefinds nothing. Do this before branching onerror, so a cancel consumes the flow too. Note what the record does not hold — the session that started the journey; see below.parseRelaycomparesstateandissin constant time and throws a typedRelayError;access_deniedis the user pressing "Back to {your name}" or declining — a normal outcome, not a failure. Any othererroris.exchangeCodeposts the form-encoded token request with HTTP Basic client authentication and a 30-second timeout, and throws anOAuthErrorcarrying the RFC 6749error. Never exchange from the browser; never retry aninvalid_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.bundlesis your store of{ token, privateKey }per user session — encrypted at rest or memory-only — andOpenBankingClient.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
The callback answers with a 302 back to your own page, carrying the session cookie it just set — so the page that renders knows whose connection it is without the flow record ever naming a session. That page then reads your own status endpoint, keyed on the same cookie:
A 401 from any read means the key is gone (revoked or expired): drop the bundle and offer to connect again. userinfo() throws a typed OAuthError with status === 401 for exactly that case, which is the reliable way to tell a revoked key from a transient failure — do not match on a read error's message.
Your page polls /api/status after the redirect lands until firstSyncPending 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:
SameSite=None; Secureon the session cookie. One line; now dependent on third-party-cookie behaviour, which browsers keep tightening.- Do not need the cookie at all (recommended, and what the examples above do): make
statea random key into a short-lived server-side record holding the verifier. The callback looks the flow up bystate, and issues the session itself — see the next section for why it must not reuse the one that started the journey. Your page then polls your own origin, a same-originGET, whereLaxis fine.
#Log in the browser that finished the flow
It is tempting to store the id of the session that started the journey on the flow record and hand that session the credentials at the callback. Do not: the browser that starts a flow and the browser that finishes it are not necessarily the same one.
An attacker opens your /connect in their own browser, abandons it, and sends you the resulting
/oauth/authorize?… link. You sign in and consent — and the callback attaches your access key
and your relayed private key to their session. They reload their tab and read your accounts.
This is login CSRF (CWE-384), and the delegated key makes the payoff your bank data.
The flow record should therefore hold the verifier and nothing about a session. At the callback, create a new session for the user you just authenticated and set its cookie on that response. The relay POST is a top-level navigation to your own origin, so the cookie you set there is first-party and the same-site page that follows carries it; a popup shares its opener's cookie jar, so the opening page picks it up on its next poll.
The same applies to anything else keyed by state. If your status endpoint answers /api/status? state=…, whoever minted the state can poll it — including the attacker above. Key the status
endpoint on the caller's session instead.
#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 hosted reference implementation runs against production with real banks — register your own staging client to follow along rather than connecting to that one.) The reference implementation at partner-demo.open-banking.io is built exactly this way.