nextjs-better-auth-session-guard
Next.js Better Auth Session Guard Pattern
Problem
In Next.js 16 App Router applications using Better Auth, session invalidation isn't automatically detected client-side. This creates a security and UX issue where:
- User signs out → header updates to signed-out state
- Protected content (dashboard, profile, etc.) remains visible
- Content only disappears after user interaction triggers re-render
- E2E tests fail because session state isn't consistent
Root Cause: Server-side auth check in layouts only runs on initial render (wrapped in Suspense). There's no client-side monitoring for session state changes after the initial server validation.
Context / Trigger Conditions
Symptoms:
- Header shows "Sign In" button but dashboard content is visible
- Users report seeing protected pages briefly after signing out
- E2E tests fail with inconsistent session state
- Test failures like "should maintain session after page reload" or "session persists"
Environment:
- Next.js 16+ with App Router
- Better Auth v1.4.10+
- Using server-side session checks in layouts (
auth.api.getSession()) - Protected routes under route groups like
(app)or(authenticated)
When This Pattern Is Needed:
- Any app with authentication using Better Auth
- Apps that need immediate session invalidation detection
- Apps with strict security requirements (SaaS, healthcare, finance)
- Apps with E2E tests that verify session persistence
Solution
Implement a dual-layer authentication approach following 2026 best practices:
Layer 1: Server-Side Initial Check (Already Implemented)
Keep existing server-side validation in your layout:
// app/[locale]/(app)/layout.tsx
async function AuthLayoutContent({ children, locale }: Props) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) {
return redirect({ href: "/sign-in", locale });
}
return <main>{children}</main>;
}
Layer 2: Client-Side Runtime Monitoring (Add This)
Step 1: Create SessionGuard Component
Create components/auth/SessionGuard.tsx:
"use client";
import { authClient } from "auth/client";
import { useEffect, useRef } from "react";
import { useRouter } from "next/navigation"; // or your routing library
interface SessionGuardProps {
children: React.ReactNode;
requireAuth?: boolean;
redirectTo?: string;
}
/**
* SessionGuard - Client-side session monitoring component
*
* Monitors session state and redirects to sign-in when session becomes invalid.
* Works alongside server-side auth check to provide real-time session invalidation detection.
*
* Key features:
* - No loading spinner (server already verified auth)
* - Uses useRef to prevent double redirects
* - Preserves return URL for post-signin redirect
* - Only triggers on session invalidation, not initial load
*/
export function SessionGuard({
children,
requireAuth = true,
redirectTo = "/sign-in",
}: SessionGuardProps) {
const { data: session, isPending } = authClient.useSession();
const router = useRouter();
const hasRedirected = useRef(false);
useEffect(() => {
// Only run after session check completes
if (isPending) return;
// Redirect if auth required but no session exists
if (requireAuth && !session && !hasRedirected.current) {
hasRedirected.current = true;
// Preserve current URL for post-signin redirect
const returnUrl = window.location.pathname + window.location.search;
router.push(`${redirectTo}?returnUrl=${encodeURIComponent(returnUrl)}`);
}
}, [session, isPending, requireAuth, redirectTo, router]);
// Render children immediately (server already verified auth)
// This just monitors for changes after initial render
return <>{children}</>;
}
Step 2: Integrate into Layout
Wrap your protected content in the SessionGuard:
// app/[locale]/(app)/layout.tsx
import { SessionGuard } from "@/components/auth/SessionGuard";
async function AuthLayoutContent({ children, locale }: Props) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) {
return redirect({ href: "/sign-in", locale });
}
return (
<SessionGuard>
<main>{children}</main>
</SessionGuard>
);
}
Step 3: Configure Session Polling
In your auth client configuration (packages/auth/src/auth-client.ts or similar):
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
baseURL: /* ... */,
plugins: [ /* ... */ ],
session: {
fetchOnWindowFocus: true, // Re-check when user returns to tab
fetchOnReconnect: true, // Re-check when network reconnects
refetchInterval: 300000, // Check every 5 minutes (reduces server load)
},
});
Polling Configuration Guidelines (2026 Best Practices):
- 5 minutes (300000ms): Good balance for most apps
- 1 minute (60000ms): High-security apps requiring faster detection
- 10 minutes (600000ms): Low-security apps prioritizing reduced server load
- Always enable
fetchOnWindowFocusandfetchOnReconnectfor better UX
Verification
Manual Testing
-
Sign out invalidation:
- Sign in → navigate to protected route (e.g.,
/dashboard) - Sign out in header
- Dashboard should redirect to
/sign-inwithin 1-2 seconds
- Sign in → navigate to protected route (e.g.,
-
Session persistence:
- Sign in → navigate to protected route
- Reload page (F5)
- Should stay signed in, content remains visible
-
Multi-tab invalidation:
- Sign in → open two tabs with protected route
- Sign out in tab 1
- Switch to tab 2 → should redirect to sign-in when focused
-
Cookie expiry:
- Sign in → navigate to protected route
- Clear cookies in DevTools
- Reload → should redirect to
/sign-in
E2E Testing Patterns
Test: Session Persistence After Reload
test("should maintain session after page reload", async ({ page }) => {
const authPage = new AuthPage(page, "en");
await authPage.goto("/");
await authPage.signIn(TEST_USER);
await authPage.expectSignedIn();
// Wait for session to be fully established
await page.waitForLoadState("networkidle");
// Verify session cookies are set before reload
const cookies = await page.context().cookies();
const sessionCookie = cookies.find((c) =>
c.name.includes("better-auth.session_token") || c.name.includes("session")
);
expect(sessionCookie).toBeDefined();
// Reload page
await page.reload({ waitUntil: "networkidle" });
// Wait for client-side session hydration (SessionGuard + useSession)
await page.waitForTimeout(2000);
// Verify session persists after reload
await authPage.expectSignedIn();
});
Key Testing Insights:
- Wait 2 seconds after reload for client-side session hydration
- Verify cookies before reload to ensure session was established
- Use
networkidleto ensure all network requests complete
Example: Complete Implementation
Here's a full example showing all pieces together:
1. Auth Client Configuration:
// packages/auth/src/auth-client.ts
export const authClient = createAuthClient({
baseURL: typeof window !== "undefined"
? window.location.origin
: process.env.NEXT_PUBLIC_APP_URL,
plugins: [/* your plugins */],
session: {
fetchOnWindowFocus: true,
fetchOnReconnect: true,
refetchInterval: 300000, // 5 minutes
},
});
2. SessionGuard Component:
// components/auth/SessionGuard.tsx
"use client";
import { authClient } from "auth/client";
import { useEffect, useRef } from "react";
import { useRouter } from "@/i18n/routing";
export function SessionGuard({ children, requireAuth = true, redirectTo = "/sign-in" }) {
const { data: session, isPending } = authClient.useSession();
const router = useRouter();
const hasRedirected = useRef(false);
useEffect(() => {
if (isPending) return;
if (requireAuth && !session && !hasRedirected.current) {
hasRedirected.current = true;
const returnUrl = window.location.pathname + window.location.search;
router.push(`${redirectTo}?returnUrl=${encodeURIComponent(returnUrl)}`);
}
}, [session, isPending, requireAuth, redirectTo, router]);
return <>{children}</>;
}
3. Layout Integration:
// app/[locale]/(app)/layout.tsx
import { SessionGuard } from "@/components/auth/SessionGuard";
async function AuthLayoutContent({ children, locale }) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return redirect({ href: "/sign-in", locale });
return (
<SessionGuard>
<main>{children}</main>
</SessionGuard>
);
}
export default async function AppLayout({ children, params }) {
const { locale } = await params;
return (
<div>
<Suspense fallback={<HeaderSkeleton />}>
<Header />
</Suspense>
<Suspense fallback={<LoadingSkeleton />}>
<AuthLayoutContent locale={locale}>{children}</AuthLayoutContent>
</Suspense>
</div>
);
}
Notes
Performance Considerations
- SessionGuard runs client-side only: No SSR impact, no duplicate work
- Server check happens first: Client monitoring is additive, not redundant
- Configurable polling: 5-minute default balances UX and server load
- useRef prevents redundant redirects: No redirect loops or duplicate navigations
Security Considerations
- Defense in depth: Server-side check (initial) + client-side monitoring (runtime)
- Both layers required: Don't skip server-side validation thinking client is enough
- Return URL validated server-side: Client preserves URL but server must validate it
- Cookie-based sessions: Better Auth uses httpOnly cookies for security
UX Considerations
- No loading spinner: Content renders immediately (server verified auth)
- Return URL preserved: Better UX when session expires during navigation
- Redirect only on invalidation: Initial page load doesn't trigger redirect
- Multi-tab support:
fetchOnWindowFocusdetects sign-out in other tabs
Edge Cases Handled
- Initial page load: Server check runs, SessionGuard doesn't redirect
- Page reload: Cookies persist, session hydrates, no redirect
- Session invalidation: Client detects within polling interval, redirects with return URL
- Race conditions:
useRefprevents double redirects - Network issues:
fetchOnReconnectre-checks when connection restored
Known Issues (Better Auth v1.4.10)
Based on 2026 research, be aware of these Better Auth limitations:
-
Server-side actions may not trigger useSession updates: Sometimes navbar reading session state using
useSessiondoesn't auto-update after server-side sign-in/sign-out. The SessionGuard pattern mitigates this with polling. -
Cache invalidation inconsistency:
useSession().refetch()doesn't supportdisableCookieCacheparameter likegetSession()does. Use polling instead of relying solely on refetch. -
Occasional state update failures: Session state may not update ~50% of the time in some setups. The 5-minute polling interval ensures eventual consistency.
Alternative Patterns (Not Recommended)
Why not middleware-only?
- Middleware doesn't detect runtime session changes
- CVE-2025-29927 vulnerability showed relying solely on middleware is insufficient
- Better Auth docs recommend checking session in Server Components, not just middleware
Why not client-only?
- Client-side checks can be bypassed
- Initial load has no protection until JavaScript loads
- Violates defense-in-depth security principles
Why not websockets/server-sent events?
- Adds complexity and infrastructure requirements
- Polling is simpler and works with serverless/edge deployments
- 5-minute interval is acceptable latency for most apps
References
Official Documentation
Best Practices (2026)
- Top 5 Authentication Solutions for Next.js 2026
- Robust Security & Authentication Best Practices in Next.js 16
- Next.js 16: What's New for Authentication