Logging in with Mobile BankID on a phone goes like this: you tap "Open BankID" in the browser, the BankID app opens, and you identify yourself. Then, by default, you are left in the BankID app, and you have to go back to the browser yourself. Not a nice experience. We wanted LogEze to bring the user back to the browser without that extra step.

The return URL

When you start a BankID order you can give BankID a returnUrl. When the user is done in the app, BankID opens that URL, and the user is back in the browser. Ours is https://www.logeze.se/#/login/bankid/return/<nonce>, where the nonce, a value used for this one login only, lets the page find the order again.

On Android the user lands in the tab they started in, logged in, the way you would hope. We do not even send a return URL there.

On an iPhone it does not. BankID's return opens a new tab. The tab you started in is still there, frozen by iOS while you were in BankID, and still waiting. The new tab could finish the login on its own, so nothing was broken, but after a few logins you had a row of LogEze tabs, some logged in and some not, which was confusing.

So the iPhone needed some extra dance moves to bring the user back to the tab they started in. This post covers what we tried, what did not work, what we ended up shipping, and what we still do not understand.

What other sites do

Some don't try. Länsförsäkringar sends no return URL at all: you switch back yourself with the small "Safari" link in the top left corner, and you land in your own tab.

Some do, and succeed. Skatteverket brings you back to the tab you started in, and so does Handelsbanken, through the same BankID app on the same phone. So the problem was solvable, and something about our setup was different.

What the web said

Not much that was official. An unanswered Apple developer forum post from 2018 says Safari reuses a tab when the URL it is asked to open equals the tab's URL, or differs only in the fragment. BankID's own sample client builds its return URL as the current page plus a fragment, window.location.href + '#initiated=true', and a comment in the same file admits that some iOS browsers still open a new tab.

What we tried

We worked in production on a real iPhone, with Safari's Web Inspector attached from a Mac and a handful of event listeners pasted into the console: hashchange, pagehide, pageshow and visibilitychange. Every attempt below ended with two tabs.

The original tab's console told the same story every time: visibilitychange to hidden when BankID opened, and never visible again.

The same URL from another app

To take BankID out of the picture we put links in the Notes app and tapped them while a LogEze tab was open. Safari reused the tab every time, including for the exact pair we use, #/login/bankid in the tab and #/login/bankid/return/test from Notes. Safari's history showed that BankID opened our return URL unchanged. So Safari kept the tab when Notes opened the URL and opened a new one when BankID did. We have not found out why.

What Handelsbanken does

Their logon page is public, so we read its script. It sends its own backend /logon/se/corp/sv/mbidqr/#authenticate as the redirect, the return arrives as a fragment with an id and a link to finish the login, and the page handles it both in the same tab and in a new one. While BankID is open the page does nothing at all. If you come back to it without the fragment, it tells you that you seem to be using a different browser. Structurally that is what we do. We could not find the difference that matters.

What we shipped

Two observations made a workaround possible. First, Safari lets a script close a tab that another app opened, while it refuses for the tab you started in ("Can't close the window since it was not opened by JavaScript"). Second, the original tab is alive but suspended while you are in BankID. A BroadcastChannel ping from the new tab reached it after 90 seconds, far too late to ask "are you there?".

So the tab that launches BankID leaves a small marker in localStorage: the order's nonce and the time. Not in sessionStorage, since that belongs to one tab, and the new tab cannot read what the original tab stored there. When BankID's return opens a new tab, that tab looks for a marker with its nonce. A fresh one means the login started in another tab of this browser, so the new tab removes the marker and calls window.close(). Safari shows the original tab, which collects the finished order when it becomes visible and logs the user in. Without a marker, or with a stale or corrupt one, the new tab stays and finishes the login itself, as before. The marker is written only on an iPhone that gets a return, and lives no longer than the BankID order, three minutes.

Here is the whole dance on an iPhone, from the first tap to the login. The return tab and its window.close() are the iPhone part. On a desktop, or with BankID on another device, there is no return tab, and the original tab polls until BankID reports the order complete.

The BankID login on an iPhone, with the return tab closing itself

The result is one tab. Sometimes you see the second one for a moment before it closes.

Firefox and Chrome

Firefox and Chrome on an iPhone have the same problem, and one more. An https return URL opens in Safari, whatever browser you started in, so a Firefox user ended up logged in to Safari, with the Firefox tab still waiting. BankID has to be given a URL that opens the right app. For Firefox that is firefox://open-url?url=<the https URL, encoded>, and for Chrome googlechromes://<host>/#/login/bankid/return/<nonce>. The page tells the backend which browser it runs in, from the user agent, and the backend builds the URL from a short list it knows. A browser that is not on the list gets no return URL, and the user switches back by hand.

Both browsers open the return in a new tab too, like Safari. Both let that tab close itself, so the marker trick works unchanged and every browser ends in one tab.

Two tabs collecting the same order showed a bug we had not seen in Safari, where the extra tab closes too fast to notice. When the user cancelled in BankID, the first tab to ask got the cancel, and the other one was told "pending" until the order expired. The backend now remembers how an order failed and gives the same answer to anyone who holds its nonce.

A tab that closes itself mid-request

After the change we noticed crashes, all the same: Early EOF while reading the body of a collect request. The new tab calls window.close() and sends one last collect as it goes. Sometimes the tab is gone before the request body has reached the server, and the server reads half a request. Firefox did it in two runs out of seven, Chrome in one out of seven, Safari never in our runs.

Nothing was broken for the user, the original tab finished the login on its own. But a half-read request ended up as a server error. Now a client that goes away before its request is read gets a 400 instead.

This is a workaround

We hide the symptom. The cause, why BankID's return opens a new tab for us and not for Handelsbanken, is still unknown. The workaround also depends on Safari allowing that window.close(), which we observed rather than found documented. If a future iOS stops allowing it, users are back to two tabs, both logged in, which is where we started and not a broken login.

If you know what makes Safari reuse the tab, we would like to hear it.