# Your BFF Is Keeping a Second Session. Why?

A while ago I wrote about [the Backend-for-Frontend pattern](https://www.abblix.com/en/docs/bff-pattern-in-oauth-and-openid-connect): move OAuth tokens off the browser and onto a server the browser talks to through a cookie. One reader disagreed, and set out his position at length and in good faith. Ten replies later neither of us had moved, we were both still polite, and I filed it away as one of those arguments where two competent people simply weigh risks differently.

That reading was wrong. I went back to the thread recently and the disagreement is not about risk tolerance at all. We were defending two different claims and never noticed.

Disclosure before anything else: at Abblix we build an OpenID Connect server library, so when I argue that a capable identity provider removes most reasons for a second session, I am arguing for a category we sell into. The product examples are ours because ours is the code I can read to the bottom. The checklist near the end works on any product, and the section after it lists where ours answers no.

## The argument, in its strongest form

If an attacker can run code on your page, he said, the attacker rides your cookie. Every request the BFF proxies is a request the attacker can make too, with the session cookie attached, from the victim's own browser. So moving tokens to the server did not remove the attacker's access. It moved the tokens out of reach and left the access in place.

And if the answer to that is "we do not trust the frontend with tokens", then encrypt the tokens and put them in the HttpOnly cookie itself. The browser cannot read them. The server does not have to remember anything. You get the same protection without a session store, without sticky sessions, without Redis.

It is not a naive position. It is most of an RFC.

## What each of us was actually defending

I was defending tokens on the backend. He was attacking state held by the backend. Those are different claims, and both of us were right about our own.

That reframing is only honest if I concede the point it lets me walk past, so: he was right about the attack. A BFF does not stop an attacker executing script in your page. It converts token exfiltration into session riding, and those are different amounts of damage. The riding attacker is confined to that browser, has to stay online, is limited to the endpoints you proxy and to whatever your rate limits and audit log allow, and above all he never gets the refresh token, which is the credential with the long life and the wide reach. That is a real gain, and a narrower one than my original article implied. What it is not is an argument about session storage.

The BFF pattern says the tokens must not reach the browser. It does not say the server must keep them in a session table. I had bundled the two together so thoroughly that when he attacked one I defended the other, and when he conceded the one I was defending, I heard it as stubbornness rather than agreement.

## What a BFF actually decides

In OAuth the client is the application the provider has registered and knows by its `client_id`; whether it can also authenticate is what splits confidential from public. Code shipped to a browser can hold no secret, so it registers as public, and part of what the best-practice documents then require follows from that one fact: PKCE, which a public client MUST use and a confidential one only SHOULD, and refresh token rotation, which [RFC 9700 asks of public clients](https://datatracker.ietf.org/doc/html/rfc9700#section-4.14.2-4). Short access tokens and narrow audiences are not on that list, since [RFC 10017 applies those to every architecture it describes](https://datatracker.ietf.org/doc/html/rfc10017#section-9.1), a BFF included.

A BFF is not another compensation. It drops the premise the compensations rest on, that the client is the code in the browser. The server registers, the server authenticates, the server holds the tokens, and the browser stops being the client. It still carries the redirects, which is why a BFF runs PKCE like everyone else, but it never holds a token and never proves who it is to the provider. It becomes a view onto that server, attached by a cookie. That was the argument of my first article, and it is the part of it I still hold.

Four questions test it, and none of them is about storage. Who is registered at the provider and authenticates as the client. Who presents the code and the refresh token at the token endpoint. Who attaches an access token to an API call. And who can read what the browser is carrying. Three answers are "the server"; the fourth is "the server too, because the browser's cookie is opaque to it". Answer that way and you are running a BFF, whether the session lives in a Redis table or in an encrypted cookie the browser holds and cannot open.

The specification draws the same line when it separates a BFF from other server-side boxes: "To avoid confusion with other architectural concepts, such as API gateways and reverse proxies, it is important to keep in mind that the BFF becomes the OAuth client for the frontend application." Two things follow. The test is deliberately over-inclusive, since a server-rendered application with cookie authentication and an HTTP client calling an API answers all four the same way, and has been running this pattern since before it had a name. And it is not vacuous, because the specification's own middle pattern fails it: a [token-mediating backend](https://datatracker.ietf.org/doc/html/rfc10017#section-6.2) registers, authenticates and keeps the refresh token, then hands the access token to the browser, so the third answer stops being the server and the fourth stops being opaque.

Which of the four my reader was changing is the question the next section settles.

## Both options are in the standard now

[RFC 10017](https://datatracker.ietf.org/doc/html/rfc10017), published in August 2026 as BCP 212, is the current best-practice document for OAuth in browser-based applications. [Section 6.1.2.3](https://datatracker.ietf.org/doc/html/rfc10017#section-6.1.2.3), "Cookie-Based Session State", describes exactly two ways to run a BFF and treats both as legitimate.

On the [server-side variant](https://datatracker.ietf.org/doc/html/rfc10017#section-6.1.2.3-2):

> Server-side sessions expose only a session identifier and keep all data on the server. Doing so ensures a great level of control over active sessions, along with the possibility to revoke any session at will. The downside of this approach is the impact on scalability, requiring solutions such as "sticky sessions" or "session replication". Given these downsides, using server-side sessions with a BFF is only recommended in small-scale scenarios.

And in the [very next paragraph](https://datatracker.ietf.org/doc/html/rfc10017#section-6.1.2.3-3), on the client-side variant:

> However, when client-side sessions are used in the context of a BFF, these properties change significantly. Since the cookie-based session is only used to obtain a user's tokens, all control and revocation properties follow from the use of access tokens and refresh tokens. It suffices to revoke the user's access token and/or refresh token to prevent ongoing access to protected resources, without the need to explicitly invalidate the cookie-based session.

[Section 6.1.3.2](https://datatracker.ietf.org/doc/html/rfc10017#section-6.1.3.2) adds that a BFF using client-side sessions with access tokens in them SHOULD encrypt the cookie contents, and it is [careful about what that buys](https://datatracker.ietf.org/doc/html/rfc10017#section-6.1.3.2-6). Encryption "does not affect the security properties of the BFF pattern"; it keeps tokens out of the browser's plaintext storage, and no more than that, because "while encrypting the cookie contents prevents direct access to embedded tokens, it still allows the attacker to use the encrypted cookie in a session hijacking attack".

So my reader had not proposed an alternative to the BFF pattern. He had proposed its other variant, now described in the same specification, in the paragraph immediately after the one I had been quoting at him.

The specification does not merely permit the client-side variant. It says that inside a BFF, revocation moves to the tokens. Which raises the question the rest of this article is about: moves to *where*, exactly?

## Revocation reaches only the code that hears about it

To the provider. When it revokes a token or a whole session, it records that on its own side, and the fact stays there. Other code learns it by asking, or by being told, when the provider pushes the news and that code is listening.

In a BFF the first place that can hear is the BFF itself, since every request the browser makes passes through it. Told by back-channel logout or by an event, it refuses the next proxied request, whatever the APIs behind it do. What it has to keep in order to refuse comes later. An access token's own lifetime decides what happens to a token that has left the BFF.

Beyond it, a resource server validating a self-contained JWT by its signature asks nobody. The signature was valid when the token was issued and is still valid after the session ends, so that API keeps accepting the token until its `exp`. The RFC's "it suffices to revoke the user's access token" holds only for an API that checks: one that never checks accepts a revoked token like any other. The revocation RFC is explicit about the gap, offering for self-contained tokens either "some (currently non-standardized) backend interaction between the authorization server and the resource server" or [short-lived tokens](https://datatracker.ietf.org/doc/html/rfc7009#section-3). Sender-constraining with DPoP or mTLS does not close it either: a constraint limits who can present a stolen token, not how long a revoked one is accepted.

What is left reaches it sideways. [Introspection](https://www.abblix.com/en/docs/glossary-overview#token-introspection) asks the provider on each request, and where the answer is cached the window is as long as the cache. A pushed event works if the server records it and checks what it recorded, which is what the [CAEP](https://www.abblix.com/en/docs/glossary-overview#caep) vocabulary further down is for. Failing both, expiry does the work, and how fast depends on how short the token is. In our server the places that consult the record are the authorization endpoint, the token endpoint, and the JWT validation that introspection and userinfo run through, so an API introspecting against us sees a revoked session on its next call.

Before weighing any reason for a second session, then, write down two things: whether your BFF checks anything per request, and which of your resource servers ask. Mark every place a token is validated rather than every service, since a gateway introspecting for the APIs behind it counts once and a service that receives the forwarded token and checks only its signature counts separately. Where the answer is cached, note the cache lifetime, because that is when revocation lands.

Where nothing asks and nothing is told, revocation means the access token lifetime, plus whatever clock skew the validator allows. In ASP.NET Core that skew is five minutes unless you changed it. The console you clicked revoke in will say the session is gone either way.

## Your identity provider is already running a session

There is a session before the BFF exists at all. The identity provider created it, and it had no choice: something authenticated the user, recorded how and when, and has to remember that this browser already proved who it belongs to so the next application does not ask again. That is what [single sign-on](https://www.abblix.com/en/docs/glossary-overview#sso) is.

Keycloak calls it a user session with an `sid`, Entra ID stamps a session identifier into its tokens, Auth0 keeps one behind its SSO cookie, and in our own server that record carries a session identifier too.

So a BFF that adds a server-side session is not introducing state to a stateless system. It is keeping a second record of a user who already has a first one, in a different process and under a different administrator, with a lifetime of its own.

Writing the store is well-trodden ground and every framework ships one. Operating it is where the bill arrives: the specification's own objection is sticky sessions and replication, and anyone who has watched a cache failover sign everybody out at once knows that bill. But the more interesting cost is not operational. There are now two copies of the same fact, and they drift. The provider ends the session; the copy in the BFF stays alive until its tokens expire.

At which point anyone who knows this field says: that is exactly what Front-Channel and Back-Channel Logout were invented for. Quite right. And that objection is this article's argument, arriving one paragraph early.

Look at what those two mechanisms are. They are not features of your BFF. They are things the *provider* does to your BFF, and they only work if both ends implement them - the provider has to send the notification, and your application has to be registered to receive it and to act on it. So the drift is fixable, and it is fixable by a provider capability. Which is the whole question. If your provider has that capability, what is the second session still doing? And if it does not, the second session is not preventing drift either. It is the thing drifting.

The two are also not equals any more, and the difference is worth knowing before you rely on either. [Front-Channel Logout](https://openid.net/specs/openid-connect-frontchannel-1_0.html) runs through the browser: the provider renders an iframe pointing at each application's logout URI. That is the shape third-party cookie blocking breaks, and the picture is uneven: Safari and Firefox block by default, while Chrome abandoned its deprecation plan in 2025 and still allows them. The specification is careful about its own exposure and [says so](https://openid.net/specs/openid-connect-frontchannel-1_0.html#ThirdPartyContent). The logout URI "might not be able to access the RP's login state when rendered by the OP in an iframe because the iframe is in a different origin than the OP's page", and deployments are advised to write defensive code and, if they can, tell the user that the logouts could not be performed. The same section notes that Back-Channel Logout "is not known to be affected by these developments", because it never touches the browser at all: it is a server-to-server call.

So of the two mechanisms invented to stop the drift, one rests on browser behavior you do not control and cannot count on everywhere, and the other carries a caveat this article comes back to. It arrives without the user's cookie.

## Try this on your own system

You do not have to take that on faith. The test takes ten minutes and needs no code, but three things have to be settled first or the result means nothing.

Write down your access token lifetime: the `exp` on a token your BFF is holding, or the client's configured lifetime. Note whether your BFF refreshes proactively or only on a 401. And open the browser network panel, so you can see that your clicking actually reaches the BFF rather than being served by a client-side router.

Now log in, then end the session at the provider: the account page, the admin console, whatever "sign out everywhere" means there. Go back and click something that provably makes a backend call, and repeat the click for each resource server on your list: the answer is per API, not per application.

Read the result against what you wrote down:

- It dies at the moment of revocation, before any refresh was due. Something is checking. Find out whether it is your BFF, told by back-channel logout or an event receiver, or the API behind it, asking through [introspection](https://www.abblix.com/en/docs/glossary-overview#token-introspection) on every call. All of them cost something, and you should know which one you are paying for.
- It dies at the first refresh after revocation. This is the common case and the honest one. Your exposure window is bounded by the access token lifetime plus the validator's clock skew, and by nothing else. That is a defensible design. It is also not "the provider revoked the session".
- It keeps working past the access token lifetime. Either the app is not calling the backend at all, or refreshes are succeeding against a session the provider considers over. Check the network panel first. And the second case may be deliberate on the provider's side: a refresh token issued with `offline_access` is one the specification says should normally survive a logout, so this can be a considered choice rather than a defect.

The middle answer is what most readers will get, and the exercise is not there to shame it. It is there to turn that window into a number you chose rather than one you inherited.

## Reasons to keep the second session, and what covers them

Most reasons for a server-side session are real. They are reasons for a session, though, not necessarily a second one shaped like the provider's.

To end a session instantly. The provider can revoke by session, for the code that asks or is told. In our server the [`ITokenRevoker`](https://www.abblix.com/en/docs/api/abblix-oidc-server/Abblix.Oidc.Server.Features.Tokens.Revocation.ITokenRevoker) interface exposes [`RevokeSessionAsync`](</api/abblix-oidc-server/Abblix.Oidc.Server.Features.Tokens.Revocation.ITokenRevoker#Abblix.Oidc.Server.Features.Tokens.Revocation.ITokenRevoker.RevokeSessionAsync(string,System.Nullable_System.DateTimeOffset_,System.Threading.CancellationToken)>) and [`RevokeSubjectAsync`](</api/abblix-oidc-server/Abblix.Oidc.Server.Features.Tokens.Revocation.ITokenRevoker#Abblix.Oidc.Server.Features.Tokens.Revocation.ITokenRevoker.RevokeSubjectAsync(string,System.Nullable_System.DateTimeOffset_,System.Threading.CancellationToken)>), implemented as a cutoff time rather than a list of live sessions, which keeps stored state proportional to revocations rather than to active users.

That shape has two edges a vendor is tempted to leave out. The cutoff record is retained for a configured period, and a retention window shorter than your longest-lived refresh token quietly stops covering it. Our startup validation rejects only the value that can never work, because token lifetimes are per client and nothing at startup knows which will be longest. And a cutoff is coarse: revoking by subject ends every session that user has on every device, which is right after a credential compromise and wrong when someone clicks "sign out this laptop".

Like any revocation, a cutoff reaches only code that asks or is told, and we announce ours to nobody: registered applications hear about a logout, never about a cutoff. An API validating JWTs by signature alone therefore keeps accepting the token until it expires.

To know that the user logged out. That is [Back-Channel Logout](https://openid.net/specs/openid-connect-backchannel-1_0.html), with a caveat that decides your architecture rather than decorating it. The logout token arrives on a server-to-server call with no user cookie attached, so a BFF running a client-side session has nothing to invalidate: the tokens are in a cookie only the browser can present. Acting on the notification means recording that this session identifier is dead and checking it on the next request.

That is server-side state, but not a session table. It is a revocation marker with an expiry, the same shape our own server chose for its cutoffs. And that is the refinement this argument has been heading toward. The question was never whether to keep state, but whether to keep a marker or a mirror.

Be precise about what that buys, because the honest claim is narrower than the tempting one. A marker can be missing: the notification never arrived, the retention window expired, the store lost it. A missing marker lets a request through. What it cannot do is assert that a session is alive after the provider has ended it, which is the mirror's characteristic failure. So a marker is wrong in one direction and a mirror in two. It also needs the same shared store a session table would need, with the same replication and the same behavior when that store is down. What changes is the size of the row, the number of rows and the direction of the failure, not the infrastructure.

One case decides whether a marker is enough at all. A logout token is not required to name a session, and when it carries only the subject there is no session identifier to mark. This is where a timestamp beats a flag: a cutoff is a moment rather than a boolean, so a subject-level cutoff covers every session that existed before it and none that start after. Our own registry stores an instant for exactly that reason, because a flag would have refused the user's next login too.

To know sooner, and about more than logout. That is what the [Shared Signals Framework](https://www.abblix.com/en/docs/shared-signals-framework) and its [CAEP](https://www.abblix.com/en/docs/glossary-overview#caep) vocabulary are for: the provider pushes "this session was revoked", "the credentials changed", "the risk level moved". The receiving half needs the same marker, for the same reason.

To show the user their active sessions. That is session management at the provider, which is the only component that can see all of them anyway. A BFF's session table knows about one application.

To survive a race between two simultaneous refreshes. This one deserves its own section, because the answer is not what most people expect.

## The rotation trap

[Rotation](https://www.abblix.com/en/docs/glossary-overview#refresh-token-rotation) exists to detect theft of a bearer token held by a client that cannot keep a secret. [RFC 9700](https://datatracker.ietf.org/doc/html/rfc9700) admits its cost in the same breath as its benefit: when both an attacker and the legitimate client use the token, one presents the invalidated copy, and the server cannot tell which. So, [in its own words](https://datatracker.ietf.org/doc/html/rfc9700#section-4.14.2-5.2.1), "it will revoke the active refresh token. This stops the attack at the cost of forcing the legitimate client to obtain a fresh authorization grant."

Ending the legitimate user's session is not a side effect there. It is the design, priced. Our implementation goes further, following the RFC's note that a refresh token may encode its grant so the server can find every token that has to go with it: a superseded token, presented again, revokes the whole family. The normative sentence requires only revoking the active token. Sweeping the family is what the implementation note anticipates and what we chose to build, and the two are worth separating if you are deciding whether to match it.

Now run two browser tabs. Both see a stale access token, both refresh. One wins and rotates; the other arrives holding the copy just superseded. The server concludes theft and kills the family. The user is not looking at a failed request a retry would fix - the user is logged out, with nothing left to retry. That is our implementation at its harshest setting, and we have no grace window to soften it.

A server-side session answers this race directly: put a lock around the refresh so the two tabs take turns, and keep the state that lock needs. That is a real capability, and it is the strongest thing a mirror does that a marker cannot.

But look at who rotation was written for. RFC 9700 [scopes it](https://datatracker.ietf.org/doc/html/rfc9700#section-4.14.2-4): authorization servers "MUST utilize one of these methods to detect refresh token replay by malicious actors for public clients". Public clients: the requirement is scoped, and a BFF is not one. And it names two methods as alternatives: [sender-constrained](https://www.abblix.com/en/docs/glossary-overview#sender-constrained-token) refresh tokens, or rotation. Sender-constrain the refresh token and you have chosen the first. Rotating on top is not defense in depth; it is the second method solving a problem the first one solved, and charging you a session store to hold its failure mode.

The [FAPI](https://www.abblix.com/en/docs/glossary-overview#fapi) 2.0 Security Profile reaches the same place from the other direction. It [requires](https://openid.net/specs/fapi-security-profile-2_0-final.html#section-5.3.2.1-2.9) that an authorization server "shall not use refresh token rotation except in extraordinary circumstances", and [explains](https://openid.net/specs/fapi-security-profile-2_0-final.html#section-5.3.2.1-3):

> The use of refresh token rotation does not provide security benefits when used with confidential clients and sender-constrained access tokens. This specification prohibits the use of refresh token rotation for security reasons as it causes user experience degradation and operational issues whenever the client fails to store or receive the new refresh token and has no option to retry.

A profile for high-value APIs, open banking among them, removed rotation for this class of client. Not as a relaxation, as a security measure. In our server that is [`ForbidRefreshTokenRotation`](https://www.abblix.com/en/docs/api/abblix-oidc-server/Abblix.Oidc.Server.Features.ClientInformation.SecurityProfileRequirements#Abblix.Oidc.Server.Features.ClientInformation.SecurityProfileRequirements.ForbidRefreshTokenRotation), and it overrides the client's own setting, which is unusual: a profile normally adds controls rather than taking one away. It is sound because the same profile requires the two controls that make the removal safe.

None of this reading is new. Other implementations reached it earlier and ship non-rotating refresh tokens as their default; ours still rotates, and the section on what we are missing says what that costs.

One condition, and it catches BFFs built the way the first half of this article describes. In the client-side variant the refresh token is in the user's cookie, not on your server. HttpOnly stops page JavaScript; it does not stop malware reading the browser profile off disk, a rogue extension, or a subdomain cookie-tossing bug - and the RFC already told us encryption does not stop the cookie being replayed. Your server still presents it, so the four questions answer the same way; what changes is that your server no longer holds it. That is a bearer token outside your control, which is precisely the case rotation exists for.

So the advice has a boundary, and the profile draws it by what constrains the token rather than by where the token sits. Turn rotation off where the refresh token is sender-constrained, because a stolen copy is then useless without the key. Leave it on where the token is a bearer token, wherever it is held: without a constraint, rotation is the only theft detection you have, including theft from your own server.

## What the client-side variant costs at refresh time

If the tokens are in the cookie, every refresh rewrites the cookie, and this is where the variant gets expensive in ways the specification does not spell out.

The new token is stored only if the browser processes that response. A `fetch` whose response is discarded, a navigation the user cancelled, a gateway timeout: the token was consumed at the provider and never written down. With rotation on, that session is dead, and it is exactly the failure [the FAPI note](https://openid.net/specs/fapi-security-profile-2_0-final.html#section-5.3.2.1-3) names - the client "fails to store or receive the new refresh token and has no option to retry".

Concurrent tabs are harder here than with a server-side session, not easier. Two tabs refresh, two responses carry `Set-Cookie`, and cookie writes are last-writer-wins with no compare-and-swap. The response arriving second can overwrite a newer refresh token with an older one. You cannot serialize them, because serializing needs shared server state - the thing you removed.

So the client-side variant does not escape the rotation question. It has the least room to answer it, because it holds a bearer token it cannot lock: rotation on risks a dead session at every lost response, rotation off removes the only detection. That is an argument about which variant suits a deployment, not an argument for bolting a session store onto the one defined by not having it.

## Reasons your provider does not cover

Locality. With a local session, the BFF ends it and the next request is refused in the same process, with no network in between. Without one there is the window from the revocation section above, lasting until the token expires or someone asks.

Price both escapes before choosing. [Introspection](https://www.abblix.com/en/docs/glossary-overview#token-introspection) puts a synchronous call to the provider on the hot path of every protected request: its latency is added to yours, and its availability becomes yours, so a provider that is down means an API that is down rather than logins that fail. Caching the result buys the latency back and reinstates the window under a new name, the cache TTL. The other escape is not free either: a token short enough to expire before anyone notices costs a refresh round trip every few minutes and the load that comes with it.

Neither touches requests already in flight. A request that passed validation forty milliseconds before the revocation lands will complete, and so will a long upload, and so will a WebSocket or SSE stream authorized once at connect and now an hour old - long-lived connections are re-checked by nobody unless you wrote the code that re-checks them. If "revoked" has to mean "that stream stops", that is a property of your connection handling, not of anyone's token.

There is a fair objection here. In a BFF every request already terminates at your server, so a session lookup is a local cache hit rather than an extra hop, and the marginal cost of state really is low. That is correct, and the cost argument for going stateless is weak. It just does not settle which state to keep.

The stronger version is the best case for the mirror in this whole piece. A local session keeps serving when the provider is unreachable, and every mechanism recommended here makes the provider's availability part of yours. If your application has to keep working through an identity outage, that is a real reason to hold a mirror, and no provider feature replaces it.

Token size. ASP.NET Core's [`ChunkingCookieManager`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.authentication.cookies.chunkingcookiemanager) splits an oversized cookie into numbered pieces - the [default](https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.authentication.cookies.chunkingcookiemanager.chunksize) is 4050 characters of `Set-Cookie` header, so rather less payload than that - and a single large cookie is not a hard wall. But its own documentation notes that browsers may also have limits on the total size and number of cookies per domain, and chunking does nothing about those. In the client-side variant that arithmetic runs on every refresh, since every refresh rewrites the cookie.

Although a token that large is also a property of your provider. Ask which claims a resource server actually reads before accepting a session store as the price of carrying the rest.

## The checklist, the other way round

Most comparisons of this kind ask "server-side or client-side" and answer with a recommendation, as if the choice were only about where to put the state. Reverse the order. Name the reason you want a second session, then ask whether your provider closes it: not "is my provider good enough", which invites you to defend it, but does this capability exist in the system I have?

| Why you want the second session | Where the answer lives | How to check yours |
|---|---|---|
| End a session immediately | Revocation by session or by subject, but only for code that asks or is told | Does an admin action stop tokens already issued? Which of your resource servers introspect, and which validate a JWT by signature alone? |
| React when the user logs out | Back-channel logout, plus a revocation marker on your side | Register a logout URI, end the session at the provider, and watch for the POST arriving at your endpoint. Metadata says what the software can do, not what this deployment does. |
| React to more than logout, sooner | Shared Signals and CAEP events, plus the same marker | Can it push a session-revoked or credential-change event, and does it do so on its own or only when a host wires it? |
| Show the user their sessions | Session management at the provider | Is there an account page listing active sessions, with a way to end one? |
| Survive concurrent refresh | Rotation turned off for confidential clients, where the refresh token stays on your server | Can you disable rotation per client, or does the profile decide? |
| End the session locally, with no network hop | Nothing. This is the honest one | How long is your access token good for, and can you afford introspection? |
| Fit oversized tokens | Nothing, but ask why they are oversized | Which claims are in the token because a resource server reads them? |
| Keep serving through a provider outage | The second session, and only the second session | Take the provider offline in a test environment. Does your application still serve a logged-in user? |
| Audit what happened in this application | Your own log, keyed by the session identifier the provider already hands you; the provider records its own events, not yours | Can you answer "what did this user do here, in this session" without correlating two systems? |
| End the application session sooner than the SSO session | Per-client session lifetimes at the provider, landing at the next refresh; a local session, when it has to end at the click | Can you set an idle or maximum lifetime for this client alone, and how long after it expires does the application keep serving? |

Read the column honestly. Several rows have an answer at the provider, and those are the ones where a second session is buying what you already own. Several do not, and they are not all the same kind of no. One of them, serving a logged-in user while the provider is unreachable, is answered by the second session and by nothing else. The other two are answered by something smaller than a mirror: your own log keyed by the provider's session identifier, and a per-client lifetime at the provider, unless your timeout has to land at the click rather than at the next refresh. Read every row twice besides, because "the standard defines it" and "your provider ships it without you wiring it" are different answers, including for us, as the next section says.

## What is missing, including in ours

Revocation does not emit an event. We ship the [CAEP](https://www.abblix.com/en/docs/glossary-overview#caep) vocabulary and the [Shared Signals](https://www.abblix.com/en/docs/shared-signals-framework) transmitter, and a host can push a session-revoked event with them. But nothing inside the server does it: revoking a session writes the cutoff and stops there. The search that turned up no production call site did turn up test code constructing the event, so it was capable of finding one. Wiring revocation to a transmitted event is work the host does today, and it should not be.

Logout does not revoke tokens by default. The setting is [`RevokeSessionTokensOnLogout`](https://www.abblix.com/en/docs/api/abblix-oidc-server/Abblix.Oidc.Server.Common.Configuration.OidcOptions#Abblix.Oidc.Server.Common.Configuration.OidcOptions.RevokeSessionTokensOnLogout), and it is off deliberately: every refresh token we issue carries `offline_access`, and [Back-Channel Logout 1.0](https://openid.net/specs/openid-connect-backchannel-1_0.html#BCActions) says those normally SHOULD NOT be revoked on logout. Turning it on is a considered departure from that, appropriate where sign-out is meant to end access rather than end the browser session. So if you run the ten-minute test above against our server as it ships and end the session by signing out, you get the third answer rather than the middle one: the session keeps working until the refresh token itself expires. The token endpoint does check for a revocation cutoff; with none written at logout, it finds nothing to refuse, and the grant is rebuilt from the token that was presented. End the session by revoking it instead and the cutoff is there, the refresh is refused, and you get the middle answer. That is our default, stated plainly, because a reader who runs the test would find it in ten minutes anyway.

The refresh race has two settings and no middle. Rotation is on, or reuse is allowed; there is no grace window in which a token superseded two seconds ago is accepted once without the family dying. The FAPI text is what showed me how sharp that is. The profile permits rotation in extraordinary circumstances, but [*conditionally*](https://openid.net/specs/fapi-security-profile-2_0-final.html#section-5.3.2.1-4): "provided that authorization servers offer clients the time-limited option to retry with the old refresh token in case of failure". The grace window is not a comfort feature. It is the condition attached to the exception, and a server without one cannot offer the escape hatch alongside the thing it is an escape from.

Two rows on that checklist are not ours to answer. An admin action that ends a session, and an account page listing them, live in whatever host embeds the library - we ship the mechanism, not the console. If you are comparing against a product you install and run, that difference is real and it is not in our favor.

## The question that was wrong

The argument under that old article did not fail because either of us was mistaken. It failed because "should a BFF keep server-side state" has no answer. It is a question about a mechanism, asked without saying what the mechanism is for.

Ask instead what the state is *shaped* like. A marker records that something ended and then expires on its own; it can go missing, and a missing one lets a request through. A mirror holds a copy of a session someone else owns, and can be wrong in both directions: stale when the provider has moved on, and gone when your store is. Most of what this article recommends is a way of keeping the first rather than the second, and the checklist is where that stops being true.

If your provider revokes sessions and can tell your applications about it, most of the mirror is habit. If it cannot, the mirror is doing the provider's job from another process - a defensible thing to do, and worth knowing that you are doing it, and what it would take to stop.

Two mistakes, one shape. I read the session table as the pattern, which is why I defended the table when he attacked it. A reader could as easily read a strong provider as a replacement for the backend, and stop at its features; that half has [its own article](https://www.abblix.com/en/docs/can-a-strong-provider-replace-your-bff), and it ends the same way. The table is not the pattern and the provider is not the backend; each serves something, and the something is what the argument was supposed to be about.

My reader was closer to right than I was willing to see, and it took a specification published a year after that conversation for me to notice.
