diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 7dc90cc..a35b3fc 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -3,6 +3,7 @@ import {cookies} from 'next/headers';
import {Sora, Inter} from 'next/font/google';
import './globals.css';
import {getServerSession} from '@/features/auth/services/serverSession';
+import {tabSessionScript} from '@/features/auth/services/tabSession';
import {
htmlThemeAttr,
parseThemeMode,
@@ -105,6 +106,16 @@ export default async function RootLayout({
suppressHydrationWarning
>
+ {/*
+ FIRST child of , and that position is the whole point: it runs
+ before the markup below it is parsed, so a tab that inherited the
+ cookies without owning the session never paints the workspace. An
+ effect inside Providers would run after the first paint instead.
+ See services/tabSession.ts for what it does and why it fails open.
+ */}
+
{children}
diff --git a/src/features/auth/providers/SessionProvider.tsx b/src/features/auth/providers/SessionProvider.tsx
index 00f2d48..820aa7a 100644
--- a/src/features/auth/providers/SessionProvider.tsx
+++ b/src/features/auth/providers/SessionProvider.tsx
@@ -10,6 +10,7 @@ import {
} from 'react';
import {useRouter} from 'next/navigation';
import {authService} from '@/features/auth/services/authService';
+import {TAB_SESSION_KEY} from '@/features/auth/services/tabSession';
import type {
AuthSession,
AuthUser,
@@ -108,6 +109,27 @@ export function SessionProvider({
async (credentials: LoginCredentials): Promise => {
const result = await authService.login(credentials);
if (result.ok) {
+ /**
+ * Re-claim the tab for the session just created.
+ *
+ * Normally the inline script in the root layout has already done this
+ * while /login rendered, so this is a no-op. It is load-bearing for one
+ * path: `logout` below CLEARS sessionStorage, and the /login it then
+ * navigates to is a client transition with no new document, so no
+ * script runs to put the marker back. Without this line, signing out
+ * and straight back in inside the same tab left it unmarked — and the
+ * next full reload of the workspace read that as an inherited session
+ * and signed the user out again.
+ *
+ * Wrapped for the same reason the clear in `logout` is: storage throws
+ * in private mode, and a failed write must not fail a good sign-in.
+ */
+ try {
+ window.sessionStorage.setItem(TAB_SESSION_KEY, '1');
+ } catch {
+ /* ignore — the guard script fails open for the same reason */
+ }
+
setSession(result.session);
setStatus('authenticated');
}
diff --git a/src/features/auth/services/tabSession.ts b/src/features/auth/services/tabSession.ts
new file mode 100644
index 0000000..95de239
--- /dev/null
+++ b/src/features/auth/services/tabSession.ts
@@ -0,0 +1,84 @@
+/**
+ * Tab-scoped session lifetime.
+ *
+ * ── The problem cookies cannot solve on their own ─────────────────────────
+ * `loyaly_session` and `loyaly_tokens` are browser-session cookies: no Max-Age,
+ * so the browser drops them when it exits. That is per BROWSER, not per TAB —
+ * one cookie jar is shared by every tab of the profile, so closing the tab a
+ * merchant signed in on leaves the jar untouched and the next tab is still
+ * authenticated. No cookie attribute exists that scopes a cookie to one tab.
+ *
+ * `sessionStorage` is the only per-tab lifetime the platform gives us: it is
+ * empty in a newly opened tab, survives a reload of the tab it belongs to, and
+ * is discarded when that tab closes. So a tab that holds the session marker is
+ * a tab that was here when the session was established; a tab without it is a
+ * tab that inherited somebody else's cookies.
+ *
+ * The marker is NOT a credential. It is the string "1". The access and refresh
+ * tokens stay sealed in the httpOnly cookie where JavaScript cannot reach them,
+ * and nothing here changes that: a forged marker buys an attacker exactly the
+ * cookies their browser already had.
+ *
+ * ── Why an inline script and not an effect ────────────────────────────────
+ * A React effect runs after the first paint, so a new tab would show the
+ * dashboard and then bounce to /login. This runs as the first child of ,
+ * before the app markup below it is parsed, so the authenticated shell is never
+ * painted at all.
+ */
+
+/** Present ⇒ this tab is the one the session belongs to. */
+export const TAB_SESSION_KEY = 'loyaly.tab-session';
+
+/**
+ * Set for the duration of one sign-out attempt, and the reason this cannot
+ * loop. `proxy.ts` redirects /login → /dashboard while the cookies verify, so a
+ * failed `POST /api/auth/logout` would otherwise land straight back on a
+ * protected page with the marker still missing, and fire again forever. If the
+ * guard finds its own flag already set it concludes the clear did not take,
+ * claims the tab and gets out of the way — degrading to the previous behaviour
+ * rather than locking somebody out of a console it cannot sign them out of.
+ */
+const RESET_FLAG = 'loyaly.tab-session.reset';
+
+/**
+ * Claim the tab for the session that is about to exist.
+ *
+ * Runs on every SIGNED-OUT document, which is what makes the no-JavaScript and
+ * pre-hydration sign-in paths work: /login itself claims the tab, so the native
+ * form POST lands on /dashboard with the marker already in place. The reset flag
+ * is cleared here because arriving signed out is proof the sign-out worked.
+ */
+const CLAIM_SCRIPT = `(function(){try{var s=window.sessionStorage;s.setItem(${JSON.stringify(
+ TAB_SESSION_KEY,
+)},'1');s.removeItem(${JSON.stringify(RESET_FLAG)});}catch(e){}})();`;
+
+/**
+ * End the session when the tab holding it is gone.
+ *
+ * Fails OPEN when sessionStorage is unreadable — private mode, blocked site
+ * data, an embedded webview. A storage API that throws must not be the thing
+ * that decides a merchant cannot use the console; the worst case is the
+ * cross-tab behaviour this file exists to change, not a lockout.
+ */
+const GUARD_SCRIPT = `(function(){var K=${JSON.stringify(
+ TAB_SESSION_KEY,
+)},R=${JSON.stringify(RESET_FLAG)},s;
+try{s=window.sessionStorage;}catch(e){return;}
+try{
+if(s.getItem(K))return;
+if(s.getItem(R)){s.setItem(K,'1');s.removeItem(R);return;}
+s.setItem(R,'1');
+}catch(e){return;}
+document.documentElement.style.visibility='hidden';
+var go=function(){location.replace('/login');};
+try{fetch('/api/auth/logout',{method:'POST',credentials:'same-origin'}).then(go,go);}catch(e){go();}
+})();`;
+
+/**
+ * The script the root layout inlines, chosen by whether the SERVER resolved a
+ * session for this request. Two separate bodies rather than one that branches on
+ * a serialised flag, so neither path can be reached by tampering with the other.
+ */
+export function tabSessionScript(hasSession: boolean): string {
+ return hasSession ? GUARD_SCRIPT : CLAIM_SCRIPT;
+}