For about a week, returning users who opened our portal watched the word "Loading…" sit on the screen for ten seconds before the login page appeared. Not sometimes. Not roughly. Ten seconds, every time, and then everything worked perfectly. First-time visitors never saw it. Only people who had signed in before.
A bug that takes a random amount of time is a performance problem. A bug that takes exactly ten seconds is a confession. Nothing in a healthy web request rounds itself off to a clean power of ten. That number is not the sum of some real work; it is the ceiling of a timeout, and a timeout means something, somewhere, is patiently waiting for a thing that is never going to arrive.
This is the story of what it was waiting for, and why the thing it waited for was blocked by a security header we were proud of.
What the portal does on mount
Our portal is a single-page app. When it loads, before it shows you anything, it tries to answer one question: are you already signed in? The polite way to do that with OIDC is a silent check. The app asks the identity provider "if this browser already has a session, hand me a fresh token without bothering the user." Our client library, oidc-client-ts, exposes this as signinSilent(), and we called it on mount from a renewSession() helper.
There are two ways that silent check can run. If the app is holding a refresh token, the library does a quiet back-channel exchange, no UI involved, and you are in. If it is not holding a refresh token, the library falls back to the older mechanism: it opens a hidden iframe pointed at the identity provider's authorize endpoint with prompt=none, and waits for that iframe to post a result back. The whole point of the iframe is that it is invisible. You are never supposed to see it, and on a healthy setup you never do, because it resolves in milliseconds.
Ours never resolved at all.
The wall we built ourselves
The iframe loads the auth host. And the auth host, like every host we run that has any business being taken seriously, ships two headers whose entire job is to say "you may not put me in a frame":
X-Frame-Options: DENYContent-Security-Policy: frame-ancestors 'none'
These are clickjacking defenses, and they are correct. An attacker who can iframe your login page can float it under a decoy, trick a user into typing real credentials into what looks like something harmless, and harvest them. frame-ancestors 'none' is the modern instruction that no origin, not even our own, may embed this page. We turned it on deliberately. It is exactly the kind of thing a security review looks for and rewards.
So when oidc-client-ts opened its hidden iframe against that host, the browser did precisely what we had told it to: it refused to render the page in a frame. And here is the cruel part. A refused frame does not throw. There is no error event for the library to catch, no rejected promise, no console line. The iframe just sits there, empty, indefinitely. From the library's point of view nothing has happened yet, so it does the only thing it can. It waits for its timeout. That timeout, the default silentRequestTimeout, is ten seconds.
Ten seconds of a hidden iframe staring at a blank wall, then the library gives up, the promise finally rejects, the app shrugs and redirects you to the real login page, and everything works. The hang was never a failure. It was a success that took the scenic route through a doomed iframe.
Two correct things, one bad seam
What made this genuinely hard to see is that nothing was broken. The security headers were right. The silent-renew fallback was right, a legitimate and widely used OIDC pattern. Every component behaved exactly as designed and exactly as any reviewer would want. The ten-second hang did not live inside any one of them. It lived in the space between them, in the assumption each made about the other. The SSO library assumed it could frame the identity provider. The identity provider assumed nobody should ever be allowed to frame it. Both assumptions were defensible. They were simply incompatible, and no single file contained the contradiction.
Why it reached for the iframe at all
That still left a question. The fast path, the refresh-token exchange, would have skipped the iframe entirely. Why were returning users landing on the slow path? Because they had no refresh token to hold. And they had no refresh token because our own portal OAuth clients had been provisioned without AllowOfflineAccess, the flag that authorizes a client to be issued one. No offline access, no refresh token, no fast path, and every returning user was shunted into the iframe that could never load.
That was the real defect, and it was a data problem spread across every tenant, not a one-line code change we could ship once. So the repair is a reconcile service that re-applies AllowOfflineAccess to the portal clients of every tenant at startup, correcting the whole fleet on the next deploy without anyone touching a tenant by hand. Refresh tokens started flowing again, and the fast path came back to life on its own.
The fix, and the lesson
The reconcile service fixed the cause. But a login should not stall for ten seconds even when it does end up on the slow path, so we hardened the seam too. renewSession() now inspects the stored user first: if there is no refresh token in hand, it short-circuits and returns nothing immediately, skips the iframe it already knows is doomed, and sends the user straight to interactive login. The refresh-token fast path is untouched. And as a backstop for any background renewal that still opens an iframe, we cut the timeout from ten seconds to five, so the worst case is half as bad.
The lesson we actually kept is about a category of bug, not this one instance. A hang is a bug, even though it emits no error, no exception, no red line in a log. The only evidence it leaves is elapsed time. And when that time is a clean round number, do not go hunting for slow work to optimize. Go hunting for a timeout, and then find the thing on the other end of it that is silently, permanently, never going to answer. Ours was an iframe, knocking politely on a door we had bolted shut on purpose.
If you would rather your login flow already knew that a locked-down auth host and a silent-renew iframe do not mix, that is a seam we have already run into so that you never have to. Authagonal ships the SSO plumbing and the security headers as one system that was tested together, not as two correct halves you get to discover are incompatible at ten seconds a page load.
Top comments (1)
"A bug that takes exactly ten seconds is a confession" is going in my notebook next to every other signal that names its own cause. An exact, round-number delay is never work — work is jittery, timeouts are precise — so the number itself was the diagnosis before you opened anything. That's the same reflex I've had to build in a much dumber environment: certain shapes of failure announce their layer the moment you stop reading them as the problem and start reading them as the fingerprint.
But the part I'll keep is "it lived in the space between them." Both the security header and the silent-renew iframe were correct in isolation, which is exactly why nobody finds this by staring at either one — the fault wasn't in a component, it was in an assumption two components made about each other. I lose the most time to this precise shape: a firewall that's right, an endpoint that's right, and a seam where one quietly rewrites what the other sends. You can code-review each side forever and never see it, because neither side is wrong; the wrongness only exists in the handoff.
And the sharpest twist is that it was your own defense that did it — the clickjacking header doing its job perfectly is what hung the login. That's the failure mode I trust least, because "we added a protection" feels like pure upside, so nobody audits the protection for what it now silently forbids. A defense that only ever adds is a defense nobody re-reads. One of the cleanest between-the-seams writeups I've seen — the ten-second confession is a keeper.