Authorization flow
Send the user to /oauth/authorize with PKCE as a redirect, and handle what comes back.
Partnerdokumentationen publiceras på engelska.
#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, expiresAt } — is what the callback reads.
Size the window for the slowest journey you will actually see, not the fastest. A first-time user signs in, reads the consent screen, picks a bank and passes that bank's strong customer authentication before your callback is ever called: 45 minutes is what the reference implementation allows; ten is short enough that a first-timer on a slow bank can miss it. The record is single-use, so a longer window costs you nothing — a state that comes back twice is still refused the second time.
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_codeswaps the emailed magic link for a 6-digit code typed on the login screen. A popup needs it; a redirect journey does not, but it keeps the whole thing in one window, which is the smoother path for a first-time user.
#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. By default it sends a magic link, which opens in a new tab — fine in a redirect journey, where that tab owns the rest of it.
challenge=pin_codeswaps the link for a 6-digit emailed code typed into the same window: required in a popup, and worth having in a redirect journey too. A user with a session skips login and lands on consent directly. -
Consent and the bank, in an order that depends on who holds the decryption key.
With your own recipient key (tokens and keys) there is nothing for the user to create or unlock, so consent comes first: your client is named, the user approves, and — if they have no bank connected yet — they go straight on to pick their bank and authenticate with it. The bank callback re-enters
/oauth/authorize, mints a fresh code, and the flow finishes on its own: the user is normally not asked to approve a second time, and lands back on your redirect URI. A first-time user gives one click and then meets their bank.With the browser key relay the grant ends in a passphrase, so key and bank both have to exist first: the consent page guides the user through choosing a passphrase for their key and connecting a bank, then re-enters
/oauth/authorizeand mints a fresh code, and only then asks for consent.Either way the re-entry means the 5-minute code lifetime never bounds how long onboarding takes.
-
The relay. On approval the browser form-posts the relay to your redirect URI — with the user's private key when your integration takes the relay, and with those fields empty when you hold the key yourself.
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.
#Redirect, or popup
Redirect is the default, and what the reference implementation at partner-demo.open-banking.io does. Send the browser to the authorize URL, let it come back to your redirect URI, and render the result. It is one journey in one window, there is nothing to poll, and it is the only shape that survives a mobile in-app browser — those block popups, lose cookies, and many banks hand off to their own app during authentication and return to whichever tab started the flow.
It is also the journey your branding is for: the login, bank picker and consent screens take over the whole page wearing your name, logo and accent, rather than appearing in a 460×760 window.
| Redirect | Popup | |
|---|---|---|
| Your page | navigates away and back | never navigates |
| Login | magic link, or challenge=pin_code | challenge=pin_code |
| Completion signal | render on return | poll your own server (+ BroadcastChannel) |
| Works on mobile | yes | no |
A popup is still supported, and is worth the extra machinery if you specifically want the page behind it to stay put — the consent page cannot be embedded in an iframe, so a popup is the closest thing to an in-page flow. If you take that route: call window.open directly in the click handler (popups need a user gesture) and fall back to location.href = authorizeUrl when it returns null; pass challenge=pin_code so the login stays in the popup instead of opening a magic link in a new tab; and do not expect to hear back from it. Once the popup navigates to open-banking.io its window.opener is severed permanently (Cross-Origin-Opener-Policy: same-origin), so detect the outcome from shared server state — your callback marks the flow connected, your page polls your own origin until it flips. Callback has the details.
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.
#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.