Authorization flow
Send the user to /oauth/authorize with PKCE, in a popup or a redirect, and handle what comes back.
La documentazione per i partner è pubblicata in inglese.
#Discovery first
Fetch https://open-banking.io/.well-known/oauth-authorization-server once and cache it for an hour. It names the endpoints, the single scope, the form_post response mode and the open_banking_io extension that describes the key relay. Use its issuer as the value you compare iss against on the callback.
#Start the flow on your server
Generate a PKCE verifier and a state, keep both in a short-lived server-side record, and send the browser to the authorization endpoint as a top-level navigation — the consent page cannot be framed (frame-ancestors 'none').
flows is your short-lived server-side store keyed by state (Redis with a TTL, or a Map in development); take(state) must return the record once and delete it. The same record — { state, verifier, sessionId, mode, expiresAt } — is what the callback reads.
Every parameter is listed in the reference. Three of them decide the shape of the flow:
redirect_urimust be one of the URIs registered on your client, character for character.code_challengeis required and onlyS256is accepted.challenge=pin_codekeeps a popup journey inside the popup (below).
#What the user sees
Every screen wears your branding — name, logo, accent, support and terms links — with a "Back to {your name}" button; without branding the header shows your partner account's name.
- Login, unless they already have a session. Email is the only login inside the flow. It sends a magic link by default, which opens in a new tab; with
challenge=pin_codethe user instead types a 6-digit emailed code into the same window. Pass it in popup mode, omit it in redirect mode — the snippet above switches onmode. A user with a session skips login and lands on consent directly. - Onboarding, if needed. A first-time user has no encryption key and no bank yet. The consent page guides them through both before asking for consent — they choose a passphrase for their key, then pick and authenticate with their bank — then re-enters
/oauth/authorizeand mints a fresh code; the 5-minute code lifetime never bounds onboarding time. - Consent. Your client is named; the user approves or declines. On approval the user enters their passphrase (the relayed private key is derived from it and only exists in their browser), and the browser form-posts the relay to your redirect URI.
Every one of these screens carries a "Back to {your name}" button. Pressing it — or declining on the consent screen — delivers a form_post with error=access_denied, error_description, iss and your state to the same redirect URI; a code the consent screen was showing is burned first, and any other code expires unused within five minutes (it never left the user's browser). Handle it as a normal outcome: consume the state, tell the user they cancelled, offer to try again.
#Popup or redirect
Support both. A popup is the closest thing to an in-page flow since the consent page cannot be embedded; a redirect is the fallback when the popup is blocked and the better choice on mobile.
| Popup | Redirect | |
|---|---|---|
| Your page | never navigates | navigates away and back |
| Login | challenge=pin_code | magic link is fine |
| Completion signal | poll your own server (+ BroadcastChannel) | render on return |
| When | the default on desktop | popup blocked, or mobile |
Popups need a user gesture: call window.open directly in the click handler. If it returns null the popup was blocked — fall back to location.href = authorizeUrl. On mobile, always use redirect mode: in-app browsers block popups and lose cookies, and many banks hand off to their own app during authentication and return to whichever tab started it.
To test the cancel path on staging, press "Back to {your name}" once on the login screen (the pre-code path) and once on the consent screen (the code-bound path, which also burns the code); both must reach your callback as access_denied.
Once the popup navigates to open-banking.io its window.opener is severed permanently (Cross-Origin-Opener-Policy: same-origin), so the popup cannot postMessage your page. Detect the outcome from shared server state instead: your callback marks the flow connected, your page polls your own origin until it flips. Read callback for the details.
#Errors
Before client_id and redirect_uri are validated an error has nowhere safe to go: the response is 400 with { "error": "invalid_request", "error_description": "…" } shown to the user agent, and nothing reaches your redirect URI.
After both validate, every other error is delivered the way success is: a form_post to your redirect URI carrying error, error_description, iss and your state. Your callback consumes the flow by state first, then branches on error before it looks for code — the callback page has the one handler that does all of it. The exact error and error_description strings are pinned in the reference.
#Timeouts
Size your own "give up" timer for the slowest realistic journey, not the fastest: creating a key and passing a bank's strong customer authentication can take several minutes for a first-time user. Prefer "still waiting…" over an abort you cannot take back.