Back to blog
FILE 0x78·THE PROMPT PARAMETER THAT DEFEATED SINGLE SIGN-ON

The prompt parameter that defeated single sign-on

September 8, 2026 · oauth, entra, debugging, security

Users on a self-service portal I maintain kept asking the same question, and it's a good one: why does this thing make me sign in when Microsoft's own apps never do? They're on a corporate laptop, they logged into Windows this morning, they open Outlook and it just works. Then they open our portal and get an account picker.

The assumption I walked in with — and I'd bet most people share it — is that the Microsoft apps are doing something privileged that a third-party web app can't. That's wrong. It's the same mechanism, available to anything doing OAuth against Entra, and we'd been actively opting out of it.

What actually signs those users in

On a Windows box that's joined to Entra (or hybrid-joined), the OS gets a Primary Refresh Token at logon, bound to the TPM. The browser has a hook that injects a header derived from it — x-ms-RefreshTokenCredential — on requests to the login endpoint. Entra sees it, recognizes the device and the user, and returns an authorization code without rendering a single pixel of UI.

Nobody is "not logging in." They're logging in constantly and never seeing it.

The part I got wrong, and had told a colleague as fact, was that this is Edge-only. It isn't, and hasn't been for a couple of years:

The real dependency isn't the browser, it's the device. No join, no PRT, no silent anything. Same for private windows.

The one-line reason it wasn't working

Our authorize call:

params = {
    "client_id": cid,
    "response_type": "code",
    "redirect_uri": f"{base}/callback",
    "scope": "openid profile email",
    "state": state,
    "prompt": "select_account",   # <-- this
}

prompt=select_account is not a hint. It's an instruction: show the account picker, no matter what. It overrides exactly the thing that would have made the sign-in invisible. Somebody added it years ago for the obvious reason — people with two work accounts kept landing in the wrong one — and it silently taxed every single-account user forever after to fix an edge case.

Deleting it is the whole fix. And it's safe in a way that isn't obvious: omitting prompt does not suppress prompts. Entra still prompts whenever it genuinely needs to — unmanaged device, no session, more than one signed-in account. You're only giving up the forced picker.

Except you can't just delete it

There's a trap. If sign-in is silent, sign-out stops meaning anything:

  1. User clicks "sign out."
  2. You clear your cookie, bounce them to the login page.
  3. They click "sign in."
  4. The PRT silently signs them back in as the same account, instantly.

They now believe your logout button is broken, and they're right. So the picker still needs to exist, just on the paths that actually want it:

if q.get("switch"):
    params["prompt"] = "select_account"
elif q.get("hint"):
    params["login_hint"] = q["hint"]

...with the post-logout redirect pointing at /login?switch=1, and a small "use a different account" link on the login page. Two entry points, one silent and one deliberate, instead of one entry point that's always maximally annoying.

While I was in there

Same file, unrelated, worth its own warning. The session cookie's signing key:

def _secret():
    return (os.environ.get("SESSION_SIGNING_SECRET")
            or os.environ.get("OAUTH_CLIENT_SECRET")
            or "some-app-name-here").encode()

I checked the deployed environment on a hunch. Neither variable was set. So the HMAC key for every session cookie was a string sitting in the source tree — and because the cookie carries the user's role, anyone who could read that string could mint an admin session. No account required. All the identity checks run when a session is created; a forged cookie skips them by definition.

Nothing looked broken, which is the entire problem. The app had a separate fallback chain for the OAuth client id, so login worked perfectly. The dead default was load-bearing and invisible for as long as it existed.

The fix isn't "pick a better default," it's no default:

def _secret():
    dedicated = os.environ.get("SESSION_SIGNING_SECRET")
    if dedicated:
        return dedicated.encode()
    for var in ("OAUTH_CLIENT_SECRET", ...):
        val = os.environ.get(var)
        if val:
            # derive, don't reuse: a key for one purpose shouldn't be a key for another
            return hmac.new(val.encode(), b"session-key-v1", hashlib.sha256).digest()
    raise RuntimeError("no session signing key configured")

Raising is correct here because the verify path already swallows exceptions and returns None — so an unconfigured deployment treats everyone as logged out rather than trusting a key an attacker knows. It fails closed. A default fails open, quietly, in production, for years.

If you write or "literal" in a function whose job is to produce a secret, you have written a vulnerability with a timer on it. Go grep for it.