How I kept user sessions in sync across devices after permission, billing, and organization changes
A practical breakdown of using Firestore refresh signals to keep active SaaS sessions fresh when permissions, billing status, or organization access changes.
- Firebase
- NextAuth
- Realtime
- Auth
- SaaS
Authentication is usually treated like a login problem.
A user signs in, the app creates a session, and the client decides what the user can see based on that session.
That works until the session is no longer the source of truth.
In PufferPOS, a user can be actively using the app while something important changes somewhere else:
- an owner revokes their access
- an admin changes their permissions
- a subscription expires from a scheduled job
- an organization setting changes
- a billing status changes
- the same user has the POS open on more than one device
The uncomfortable part is that the user can still have a technically valid session while their access is no longer valid.
That was the real problem.
Not "how do I authenticate users?"
The better question was:
How do I make already-open clients react when the business state around their session changes?
The naive version
The first version of this problem is easy to underestimate.
You check permissions when the user logs in. You check permissions when they navigate to a page. You check permissions in server actions before doing anything sensitive.
That sounds reasonable.
And server-side enforcement is still required. I do not trust the client for authorization. The server still checks the user, organization, role, permissions, billing state, and whether the action is allowed.
But server checks alone do not create a good product experience.
Imagine a cashier has the POS open.
Their access is revoked by the owner from another device. The server may reject the next protected action, but the cashier may still be looking at the POS screen as if everything is normal.
Or imagine a subscription expires from a cron job. The organization's billing state has changed, but the active browser session does not automatically know that.
Or an owner changes a manager's permissions. The user may continue seeing UI they should no longer see until they refresh, navigate, or hit a server error.
That is technically safe if the backend is strict, but it is not enough.
The client should update as soon as the session-relevant state changes.
The shape of the solution
I did not need a complex event system for this.
I needed a lightweight invalidation signal.
When something happens that should cause a user's session to be refreshed, the server writes to a Firestore document for that user.
The client listens to that document in real time.
When the document changes, the client refreshes its session, revalidates the active organization, and then either refreshes the UI or redirects the user to the correct place.
The flow looks like this:
Admin / cron / owner action
|
Server updates permissions, billing, or organization state
|
Server writes sessionRefreshSignals/{uid}
|
Every active client for that uid receives the Firestore update
|
Client calls update() to refresh the session
|
Client refreshes the route or redirects if access changedI like this model because the signal document is not the session itself.
It does not contain sensitive authorization data. It does not decide what the user can do. It only tells active clients: "something changed; refresh your session."
The Firestore document
I used one document per user:
sessionRefreshSignals/{uid}The document contains a small payload:
type SessionRefreshSignal = {
version: number;
reason:
| "permissions_changed"
| "organization_revoked"
| "subscription_activated"
| "organization_settings_changed"
| "billing_status_changed";
orgId: string | null;
updatedBy: string;
updatedAt: Timestamp;
};The important field is version.
Every time the server writes a signal, it increments the version. The client stores the last version it handled in sessionStorage, so it does not repeatedly refresh for the same signal.
The reason is useful for UX and routing decisions. For example, if the reason is organization_revoked, the client should not just refresh the current page. It should move the user out of that organization context.
Writing a signal from the server
This is the server utility I use to write refresh signals.
import "server-only";
import { admin_db } from "@/firebase/admin";
import { FieldValue } from "firebase-admin/firestore";
export type SessionRefreshReason =
| "permissions_changed"
| "organization_revoked"
| "subscription_activated"
| "organization_settings_changed"
| "billing_status_changed";
export const SESSION_REFRESH_SIGNALS_COLLECTION = "sessionRefreshSignals";
export function sessionRefreshSignalsEnabled() {
return process.env.NEXT_PUBLIC_ENABLE_SESSION_REFRESH_SIGNALS === "true";
}
export function sessionRefreshSignalPayload({
reason,
orgId,
updatedBy,
}: {
reason: SessionRefreshReason;
orgId: string | null;
updatedBy: string;
}) {
return {
version: FieldValue.increment(1),
reason,
orgId,
updatedBy,
updatedAt: FieldValue.serverTimestamp(),
};
}The payload is intentionally small.
There is no permissions object here. No organization snapshot. No billing data. No member profile.
The signal is just an invalidation mechanism.
That distinction matters because it keeps the system simple. The source of truth remains the server-side session refresh logic, not a client-readable Firestore document.
Signaling one user
Some changes only affect one user.
For example, if an owner revokes a specific member's access, I only need to signal that member.
export async function writeSessionRefreshSignal({
uid,
reason,
orgId,
updatedBy,
}: {
uid: string;
reason: SessionRefreshReason;
orgId: string | null;
updatedBy: string;
}) {
if (!sessionRefreshSignalsEnabled()) return;
await admin_db
.collection(SESSION_REFRESH_SIGNALS_COLLECTION)
.doc(uid)
.set(
sessionRefreshSignalPayload({
reason,
orgId,
updatedBy,
}),
{ merge: true }
);
}The environment flag is intentional.
This kind of feature touches session behavior, routing, and realtime listeners. I wanted the ability to ship it behind a flag, test it safely, and turn it off without removing the code.
Signaling many users
Other changes affect many users.
If an organization's billing status changes, every active member of that organization may need a refreshed session. If organization settings affect what the UI should expose, multiple clients may need to react.
For that, I added a batched utility.
export async function writeSessionRefreshSignalsForUsers({
uids,
reason,
orgId,
updatedBy,
}: {
uids: string[];
reason: SessionRefreshReason;
orgId: string | null;
updatedBy: string;
}) {
if (!sessionRefreshSignalsEnabled() || uids.length === 0) return;
const chunkSize = 450;
for (let index = 0; index < uids.length; index += chunkSize) {
const batch = admin_db.batch();
for (const uid of uids.slice(index, index + chunkSize)) {
batch.set(
admin_db.collection(SESSION_REFRESH_SIGNALS_COLLECTION).doc(uid),
sessionRefreshSignalPayload({
reason,
orgId,
updatedBy,
}),
{ merge: true }
);
}
await batch.commit();
}
}The chunking is there because Firestore batched writes have limits. I used 450 instead of pushing right up to the limit, because I would rather leave margin than debug a billing update failing because it tried to write too much in one batch.
Again, this is not the core authorization layer.
This is only the notification layer.
The actual permission and billing decisions still happen on the server when the session is refreshed and when protected actions run.
Listening from the client
The client listener lives near the authenticated app shell.
Its job is:
- wait until the user is authenticated
- subscribe to
sessionRefreshSignals/{uid} - ignore missing or invalid signals
- ignore versions already handled by this browser tab
- debounce rapid changes
- call
update()from NextAuth - refresh the current route or redirect the user
Here is the core listener.
"use client";
import { useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import { useSession } from "next-auth/react";
import { doc, onSnapshot } from "firebase/firestore";
import { db } from "@/firebase/config";
const ENABLED =
process.env.NEXT_PUBLIC_ENABLE_SESSION_REFRESH_SIGNALS === "true";
const COLLECTION = "sessionRefreshSignals";
export function SessionRefreshSignalListener() {
const router = useRouter();
const { data: session, status, update } = useSession();
const refreshInFlightRef = useRef(false);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (!ENABLED || status !== "authenticated" || !session?.user?.uid) {
return;
}
const uid = session.user.uid;
const handledKey = `session-refresh:lastVersion:${uid}`;
const unsubscribe = onSnapshot(
doc(db, COLLECTION, uid),
(snapshot) => {
if (!snapshot.exists()) return;
const version = Number(snapshot.data().version ?? 0);
if (!Number.isFinite(version) || version <= 0) return;
const lastHandled = Number(
window.sessionStorage.getItem(handledKey) ?? 0
);
if (version <= lastHandled) return;
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
debounceRef.current = setTimeout(async () => {
if (refreshInFlightRef.current) return;
refreshInFlightRef.current = true;
try {
const reason = snapshot.data().reason;
const activeOrgId =
session.user.activeOrg?.id ?? session.user.lastActiveOrgId;
const refreshed = await update(
activeOrgId ? { activeOrgId } : undefined
);
window.sessionStorage.setItem(handledKey, String(version));
const orgs = refreshed?.user?.orgs ?? [];
const revokedOrgs = refreshed?.user?.revokedOrgs ?? [];
const refreshedActiveOrgId = refreshed?.user?.activeOrg?.id;
if (reason === "organization_revoked") {
router.replace("/select-org");
return;
}
if (!refreshedActiveOrgId) {
router.replace(
orgs.length > 0 || revokedOrgs.length > 0
? "/select-org"
: "/onboarding"
);
return;
}
router.refresh();
} catch (error) {
console.error("[session-refresh] failed to refresh session", error);
} finally {
refreshInFlightRef.current = false;
}
}, 250);
},
(error) => {
console.error("[session-refresh] signal listener failed", error);
}
);
return () => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
unsubscribe();
};
}, [
session?.user?.activeOrg?.id,
session?.user?.lastActiveOrgId,
session?.user?.uid,
status,
update,
router,
]);
return null;
}There are a few small choices here that matter.
Why I store the last handled version in sessionStorage
Firestore listeners can run when the component mounts and receives the current document state.
That is useful, but I do not want to refresh the session every time the user navigates and the listener is mounted again.
So the client stores the last handled version:
const handledKey = `session-refresh:lastVersion:${uid}`;
const lastHandled = Number(
window.sessionStorage.getItem(handledKey) ?? 0
);
if (version <= lastHandled) return;I used sessionStorage, not localStorage, because I wanted the dedupe behavior to be scoped to the current browser tab.
If the same user has the app open in two tabs, both tabs should be able to receive and handle the refresh signal independently.
That is exactly the kind of edge case this feature exists for.
Why I debounce the refresh
Some actions can cause multiple writes close together.
For example, an admin action might update organization settings, member permissions, and billing-derived flags in a short window. I do not want three session refreshes fighting each other.
So the listener waits briefly before running:
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
debounceRef.current = setTimeout(async () => {
// refresh session
}, 250);The debounce is small enough to feel instant, but it prevents obvious duplicate work.
Why I guard against refreshes in flight
A debounce is not enough by itself.
If a refresh is already running and another signal arrives, I do not want overlapping calls to update().
if (refreshInFlightRef.current) return;
refreshInFlightRef.current = true;
try {
// refresh session
} finally {
refreshInFlightRef.current = false;
}This keeps the client from entering a messy state where multiple session refreshes and route refreshes happen at the same time.
Why the reason matters
Most signals can end with a route refresh.
If permissions changed, the UI may need to hide or show actions. If billing status changed, the app may need to expose or block certain flows. If organization settings changed, server-rendered data may need to reload.
For those, router.refresh() is enough.
But revocation is different.
if (reason === "organization_revoked") {
router.replace("/select-org");
return;
}If the active organization was revoked, I do not want to keep the user inside that organization and wait for individual pages to fail.
The product behavior should be direct:
Your current organization context is no longer valid. Choose another one or continue through the correct flow.
That is why the listener checks whether the refreshed session still has an active organization.
if (!refreshedActiveOrgId) {
router.replace(
orgs.length > 0 || revokedOrgs.length > 0
? "/select-org"
: "/onboarding"
);
return;
}This is the part where the feature becomes more than a technical sync mechanism. It becomes product state management.
Where this gets triggered
The useful thing about this design is that it can be called from any server-side workflow that changes session-relevant state.
For example:
await writeSessionRefreshSignal({
uid: memberUid,
reason: "organization_revoked",
orgId,
updatedBy: adminUid,
});Or when many users are affected:
await writeSessionRefreshSignalsForUsers({
uids: organizationMemberUids,
reason: "billing_status_changed",
orgId,
updatedBy: "system",
});The caller does not need to know how the client refreshes.
It only needs to know that the user's current session may now be stale.
That made the mental model simple:
If this server action changes what a user is allowed to see or do, emit a session refresh signal.
The edge cases that shaped the design
The feature looks small, but the edge cases are the real reason it exists.
One user, multiple tabs
If the same user has the POS open in one tab and the dashboard open in another, both should react.
A single server-side session update is not enough. Each active client needs to hear that it should refresh.
Multiple users in the same organization
Billing and organization changes are not always user-specific.
If a subscription expires, every active member may need updated UI state. That is why the server utility supports writing signals for many users.
Revoked access while inside the product
Revocation should not wait for the next server error.
If the active organization is gone from the refreshed session, the user should be moved out of that organization context.
Stale UI is not the same as insecure UI
A stale UI can still be secure if the server rejects unauthorized actions.
But a stale UI is still bad product behavior.
For a POS system, this matters because users often keep screens open for long periods. They do not refresh like developers do.
Listener cleanup
Realtime listeners need cleanup.
The component clears pending debounce timers and unsubscribes from Firestore when it unmounts.
return () => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
unsubscribe();
};Small detail, but important. This listener lives near the authenticated shell, so it should be boring and predictable.
What this does not replace
This does not replace authorization.
The server still needs to enforce permissions on every protected operation.
The client listener is for freshness and UX. It makes the interface catch up to the current business state faster.
I think that separation is important:
- Server checks protect the system.
- Session refresh signals update active clients.
- UI changes guide the user into the correct state.
If I relied on the listener for security, the design would be wrong.
What I would improve next
This version is intentionally simple.
If the system grows, I would probably improve it in a few ways.
1. Add a small event log
Right now, the signal document only stores the latest reason.
That is enough for invalidation, but not great for debugging.
A lightweight append-only log could help answer questions like:
- who triggered the refresh?
- what changed?
- how many users were signaled?
- did this happen from billing, admin, or cron?
I would not make the client depend on the log. The client should still only need the latest signal. But logs would help with observability.
2. Move toward typed domain events
The current model has typed reasons, but the system could become more intentional.
For example:
type DomainEvent =
| { type: "member.permissions.changed"; uid: string; orgId: string }
| { type: "member.access.revoked"; uid: string; orgId: string }
| { type: "organization.billing.expired"; orgId: string };Then a separate handler could decide which users need session refresh signals.
That would separate "what happened" from "who needs to refresh."
I did not need that abstraction yet, but it is the direction I would take if this grew.
3. Better user-facing messages
The current implementation mostly redirects or refreshes.
Later, I may add contextual messages:
- "Your permissions were updated."
- "Your access to this organization was revoked."
- "Your organization's billing status changed."
I would be careful with this, though. Some permission changes should not become noisy. In a POS, interruption has a cost.
4. More explicit active organization fallback
The listener already handles missing active organization state, but this can become more nuanced with multi-branch accounts.
If a user loses access to one branch but still has access to another, the app should probably move them to the best available organization automatically or ask them to choose.
That logic belongs in the session refresh flow, not scattered across pages.
Where this sits in the overall system
The backend still handles everything that matters for security. Every protected operation checks the user, organization, role, billing state, and whether the action is allowed. That does not change.
The refresh signal sits one layer up. It is how active clients learn that something changed, so they do not stay in a stale state until the next server error teaches them.
In a POS context that separation matters more than it might in other apps. Staff keep screens open for hours. A cashier should not need a rejected request to find out their access was changed ten minutes ago.
The full system is: server enforces, signal notifies, UI catches up. Remove any of the three and something breaks badly enough that users notice.