Authentication State vs. Session State: The Distinction That Explains Half Your Logout Bugs
Here's a bug report that should feel familiar:
Here's a bug report that should feel familiar:
User clicked "Log out." They were returned to the login page. They clicked "Sign in" and were immediately logged back into the application without entering a password.
Every engineer's first reaction is that logout is broken. It usually isn't. What's actually happening is that two different pieces of state exist, the user destroyed one of them, and the other one — which they never saw and don't know about — silently re-authenticated them.
Those two things are authentication state and session state, and conflating them is the source of a startling share of identity bugs.
Two different questions
Authentication state answers: has this person proved who they are to the identity provider, and how recently?
It lives at the identity provider. It's what makes single sign-on work — the second application you visit doesn't prompt you, because the IdP already knows who you are and issues an assertion without asking again.
Session state answers: does this specific application currently consider this person logged in?
It lives at the application. It's the cookie your app sets, the session record in your store, the thing your middleware checks before serving a page.
They're related — session state is normally created because authentication happened — but they are different objects, with different lifetimes, stored in different places, owned by different systems.
flowchart TD
User["User"] --> IdP["Identity Provider"]
IdP -.->|"AUTHENTICATION STATE\n'I know who you are'"| IdPS["IdP session\n(one, shared)"]
IdP --> A1["App A session"]
IdP --> A2["App B session"]
IdP --> A3["App C session"]
A1 -.->|"SESSION STATE\n'you are logged into me'"| Note["Independent,\nper-application"]
Look at that diagram and the logout bug explains itself. There's one authentication state and N session states. Destroying one session state leaves the authentication state fully intact — so the next authorization request gets satisfied instantly, with no prompt, and the user watches themselves get logged back in.
That's not a bug. That's SSO working exactly as designed. The bug is that "log out" meant something narrower than the user assumed.
Why this distinction generates so many bugs
The lifetimes are unrelated. Your app's session might last 30 minutes of inactivity. The IdP's authentication state might last 12 hours. So a user "times out" of your app and gets seamlessly re-authenticated — which is either a delightful UX or a compliance violation, depending entirely on who's asking. A customer whose policy mandates a 30-minute idle timeout will consider that broken, and they'll be right, because from their perspective the user was never actually logged out.
Different things invalidate them. Your app can destroy its session freely. It cannot destroy the IdP's authentication state — only the IdP can, and only if the app asks properly. Conversely, an admin terminating the IdP session doesn't automatically kill the six application sessions it produced.
Only one of them is under your control. In a federated setup, the authentication state might live at a customer's Azure AD. Your app can't see it, can't invalidate it, and can't tell you when it expires. You're building session logic on top of state you have no visibility into.
Tokens add a third thing. An access token's lifetime is independent of both — it's valid until it expires, regardless of whether the session that produced it still exists. A refresh token is a fourth. It's genuinely possible to have a destroyed app session, a live IdP authentication state, a still-valid access token, and a refresh token that will happily mint more.
That's four independent lifetimes for what a user thinks of as "being logged in." Most confusing identity behavior traces back to two of them disagreeing.
The four types of logout
Because there are multiple states, "log out" isn't one operation. It's at least four, and the difference matters enormously.
Local logout. Destroy this application's session. The IdP still knows the user, other apps are unaffected, tokens remain valid until expiry. Fast, simple, and the source of the bug at the top of this article when users expect more.
RP-initiated logout. The app redirects the user to the IdP's end-session endpoint, which destroys the authentication state too. Now "sign in again" actually prompts for credentials. This is what most users mean by "log out."
Front-channel logout. The IdP, having ended the authentication state, renders hidden iframes pointing at every other application's logout URL, so their sessions get destroyed too. It works via the user's browser — which means it fails silently if the browser blocks third-party cookies, if a tab is closed mid-flow, or if an app is slow to respond. Increasingly unreliable as browsers tighten privacy controls, which is exactly why the next one exists.
Back-channel logout. The IdP sends server-to-server notifications to each application: "session X is over." No browser involvement, so no third-party cookie problem. More reliable, more to implement, and it requires each app to maintain a mapping from IdP session identifiers to its own sessions — which is precisely the mapping teams skip when they build local logout first and never revisit it.
sequenceDiagram
participant U as User
participant App as App A
participant IdP as Identity Provider
participant B as App B
U->>App: Click "Log out"
App->>App: Destroy local session
App->>IdP: Redirect to end_session
IdP->>IdP: Destroy authentication state
IdP->>B: Back-channel logout notification
B->>B: Destroy its session
IdP->>U: Redirect to post-logout page
The reason logout is famously hard isn't that any one of these is difficult. It's that "log out" is ambiguous, different stakeholders mean different things by it, and getting the full version right requires every participating application to implement its side correctly.
The idle timeout trap
This one deserves its own section because it comes up in nearly every enterprise deal.
A customer's compliance framework specifies a 15-minute idle timeout. You implement it in your application, demo it, and everyone's happy.
Then their auditor tests it. Session expires after 15 minutes — correct. The auditor refreshes the page — and is immediately back in the application, no prompt, no credentials.
Your app did exactly what was asked. But the IdP's authentication state was still alive, so the redirect to /authorize came back with a fresh assertion instantly. From the auditor's perspective, the timeout does nothing.
The fix is to make the requirement explicit at the protocol level: send max_age on the authorization request. It tells the IdP "only satisfy this without prompting if the user authenticated within N seconds; otherwise force re-authentication." And then verify the auth_time claim in the response rather than trusting the IdP honored it.
The general principle is worth extracting: an application-level session policy is not enforceable unless the authentication layer participates. If your timeout only clears your own cookie, it's a UI behavior, not a security control.
Getting it right
Name the two things separately in your code. If your codebase has one concept called "session," you will conflate them. authenticationState and applicationSession as distinct terms is a small change that prevents a category of bugs.
Decide what "log out" means in your product, and be explicit with users. "Sign out of this app" and "Sign out everywhere" are different buttons. Products that offer only the first and label it "Log out" generate support tickets and, on shared computers, genuine security incidents.
Track the IdP session identifier. When you create an application session from an OIDC login, store the sid claim alongside it. Without that mapping, back-channel logout is unimplementable — you'll receive a notification saying "this IdP session ended" and have no way to know which of your sessions it corresponds to. Teams discover this gap when an enterprise customer requires single logout, which is the worst time to discover it.
Don't assume token expiry equals session end. A valid access token outlives the session that created it. If "log out" needs to actually stop API access, you need explicit revocation, or short enough token lifetimes that the gap is acceptable — and you should know which of those you chose.
Test the full matrix. Log out of app A, then visit app B. Log out at the IdP, then use app A. Let the app session expire while the IdP session lives. Let the IdP session expire while the app session lives. Each of these four cases has a correct behavior, and each one is a bug your users will find if you don't.
Why it's worth the precision
Nearly every confusing identity bug — the phantom re-login, the timeout that doesn't time out, the logout that leaves three other apps open, the revoked user whose API calls keep working — is the same underlying error: treating multiple independent pieces of state as though they were one.
Once you can name the four lifetimes (application session, authentication state, access token, refresh token) and say which system owns each and what invalidates it, these bugs stop being mysterious. They become predictable consequences of state that was never designed to be coupled.
That's usually the whole fix: not new code, just knowing which state you're actually looking at.