home / skills / cameronapak / bknd-skills / bknd-session-handling

bknd-session-handling skill

/skills/bknd-session-handling

This skill helps manage user sessions in Bknd apps by coordinating JWT lifecycles, storage, renewal, and invalidation.

npx playbooks add skill cameronapak/bknd-skills --skill bknd-session-handling

Review the files below or copy the command above to add this skill to your agents.

Files (1)
SKILL.md
18.7 KB
---
name: bknd-session-handling
description: Use when managing user sessions in a Bknd application. Covers JWT token lifecycle, session persistence, automatic renewal, checking auth state, invalidating sessions, and handling expiration.
---

# Session Handling

Manage user sessions in Bknd: token persistence, session checking, auto-renewal, and invalidation.

## Prerequisites

- Bknd project with auth enabled (`bknd-setup-auth`)
- Auth strategy configured and working (`bknd-login-flow`)
- For SDK: `bknd` package installed
- For React: `@bknd/react` package installed

## When to Use UI Mode

- Viewing JWT configuration in admin panel
- Checking cookie settings
- Testing session expiration

**UI steps:** Admin Panel > Auth > Configuration > JWT/Cookie settings

## When to Use Code Mode

- Implementing session persistence in frontend
- Checking authentication state on page load
- Handling token expiration gracefully
- Implementing auto-refresh patterns
- Server-side session validation

## How Sessions Work in Bknd

Bknd uses **stateless JWT-based sessions**:

1. **Login** - Server creates signed JWT with user data, returns token
2. **Storage** - Token stored in cookie (automatic) or localStorage/header (manual)
3. **Requests** - Token sent with each request for authentication
4. **Validation** - Server validates signature and expiration
5. **Renewal** - Cookie can auto-renew; header tokens require manual refresh

**Key Concept:** No server-side session storage. Token itself is the session.

## Session Configuration

### JWT Settings

```typescript
import { defineConfig } from "bknd";

export default defineConfig({
  auth: {
    enabled: true,
    jwt: {
      secret: process.env.JWT_SECRET!,  // Required for production
      alg: "HS256",                       // Algorithm: HS256 | HS384 | HS512
      expires: 604800,                    // 7 days in seconds
      issuer: "my-app",                   // Token issuer claim
      fields: ["id", "email", "role"],    // User fields in token payload
    },
  },
});
```

**JWT options:**

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `secret` | string | `""` | Signing secret (256-bit min for production) |
| `alg` | string | `"HS256"` | HMAC algorithm |
| `expires` | number | - | Token lifetime in seconds |
| `issuer` | string | - | Issuer claim (iss) |
| `fields` | string[] | `["id","email","role"]` | User fields encoded in token |

### Cookie Settings

```typescript
{
  auth: {
    cookie: {
      secure: process.env.NODE_ENV === "production",  // HTTPS only
      httpOnly: true,                                  // No JS access
      sameSite: "lax",                                 // CSRF protection
      expires: 604800,                                 // Match JWT expiry
      renew: true,                                     // Auto-extend on activity
      path: "/",                                       // Cookie scope
      pathSuccess: "/dashboard",                       // Redirect after login
      pathLoggedOut: "/login",                         // Redirect after logout
    },
  },
}
```

**Cookie options:**

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `secure` | boolean | `true` | Require HTTPS |
| `httpOnly` | boolean | `true` | Block JavaScript access |
| `sameSite` | string | `"lax"` | `"strict"` \| `"lax"` \| `"none"` |
| `expires` | number | `604800` | Cookie lifetime (seconds) |
| `renew` | boolean | `true` | Auto-renew on requests |
| `pathSuccess` | string | `"/"` | Post-login redirect |
| `pathLoggedOut` | string | `"/"` | Post-logout redirect |

## SDK Approach

### Session Persistence with Storage

```typescript
import { Api } from "bknd";

// Persistent sessions (survives page refresh/browser restart)
const api = new Api({
  host: "http://localhost:7654",
  storage: localStorage,  // Token persisted
});

// Session-only (cleared when tab closes)
const api = new Api({
  host: "http://localhost:7654",
  storage: sessionStorage,  // Token cleared on tab close
});

// No persistence (token in memory only)
const api = new Api({
  host: "http://localhost:7654",
  // No storage = token lost on page refresh
});
```

### Check Session on App Start

```typescript
async function initializeAuth() {
  const api = new Api({
    host: "http://localhost:7654",
    storage: localStorage,
  });

  // Check if existing token is still valid
  const { ok, data } = await api.auth.me();

  if (ok && data?.user) {
    console.log("Session valid:", data.user.email);
    return { api, user: data.user };
  }

  console.log("No valid session");
  return { api, user: null };
}

// On app mount
const { api, user } = await initializeAuth();
```

### Session State Management

```typescript
import { Api } from "bknd";

class SessionManager {
  private api: Api;
  private user: User | null = null;
  private listeners: Set<(user: User | null) => void> = new Set();

  constructor(host: string) {
    this.api = new Api({ host, storage: localStorage });
  }

  // Initialize - call on app start
  async init() {
    const { ok, data } = await this.api.auth.me();
    this.user = ok ? data?.user ?? null : null;
    this.notifyListeners();
    return this.user;
  }

  // Get current session
  getUser() {
    return this.user;
  }

  isAuthenticated() {
    return this.user !== null;
  }

  // Login - creates new session
  async login(email: string, password: string) {
    const { ok, data, error } = await this.api.auth.login("password", {
      email,
      password,
    });

    if (!ok) throw new Error(error?.message || "Login failed");

    this.user = data!.user;
    this.notifyListeners();
    return this.user;
  }

  // Logout - destroys session
  async logout() {
    await this.api.auth.logout();
    this.user = null;
    this.notifyListeners();
  }

  // Refresh session (re-validate token)
  async refresh() {
    const { ok, data } = await this.api.auth.me();
    this.user = ok ? data?.user ?? null : null;
    this.notifyListeners();
    return this.user;
  }

  // Subscribe to session changes
  subscribe(callback: (user: User | null) => void) {
    this.listeners.add(callback);
    return () => this.listeners.delete(callback);
  }

  private notifyListeners() {
    this.listeners.forEach((cb) => cb(this.user));
  }
}

type User = { id: number; email: string; role?: string };

// Usage
const session = new SessionManager("http://localhost:7654");
await session.init();

session.subscribe((user) => {
  console.log("Session changed:", user?.email || "logged out");
});
```

### Cookie-Based Sessions (Automatic)

```typescript
const api = new Api({
  host: "http://localhost:7654",
  tokenTransport: "cookie",  // Use httpOnly cookies
});

// Login sets cookie automatically
await api.auth.login("password", { email, password });

// All requests include cookie automatically
await api.data.readMany("posts");

// Logout clears cookie
await api.auth.logout();
```

**Cookie mode advantages:**

- HttpOnly = XSS protection (JavaScript can't access token)
- Auto-renewal on every request (if `cookie.renew: true`)
- No manual token management
- Automatic CSRF protection with `sameSite`

### Header-Based Sessions (Manual)

```typescript
const api = new Api({
  host: "http://localhost:7654",
  storage: localStorage,
  tokenTransport: "header",  // Default
});

// Token stored in localStorage, sent via Authorization header
await api.auth.login("password", { email, password });

// Token automatically included:
// Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
```

## Handling Session Expiration

### Detect Expired Token

```typescript
async function makeAuthenticatedRequest<T>(fn: () => Promise<T>): Promise<T> {
  try {
    return await fn();
  } catch (error) {
    // Check if error is due to expired session
    if (isAuthError(error)) {
      // Session expired - redirect to login or refresh
      await handleExpiredSession();
    }
    throw error;
  }
}

function isAuthError(error: unknown): boolean {
  if (error instanceof Error) {
    return error.message.includes("401") || error.message.includes("Unauthorized");
  }
  return false;
}

async function handleExpiredSession() {
  // Option 1: Redirect to login
  window.location.href = "/login?expired=true";

  // Option 2: Show re-authentication modal
  // showReauthModal();

  // Option 3: Try to refresh (if using refresh tokens)
  // await refreshToken();
}
```

### Auto-Refresh Pattern

Since Bknd uses stateless JWT, there's no built-in refresh token. Instead, use `api.auth.me()` to re-validate and extend cookie-based sessions:

```typescript
class SessionWithAutoRefresh {
  private api: Api;
  private refreshInterval: number | null = null;

  constructor(host: string) {
    this.api = new Api({
      host,
      tokenTransport: "cookie",  // Cookie auto-renews on requests
    });
  }

  // Start periodic session check
  startAutoRefresh(intervalMs = 5 * 60 * 1000) {
    // Every 5 minutes
    this.refreshInterval = window.setInterval(async () => {
      const { ok } = await this.api.auth.me();
      if (!ok) {
        this.stopAutoRefresh();
        this.onSessionExpired();
      }
    }, intervalMs);
  }

  stopAutoRefresh() {
    if (this.refreshInterval) {
      clearInterval(this.refreshInterval);
      this.refreshInterval = null;
    }
  }

  private onSessionExpired() {
    // Handle expired session
    window.location.href = "/login?session=expired";
  }
}
```

### Proactive Token Refresh

For header-based auth, re-login before token expires:

```typescript
import { jwtDecode } from "jwt-decode";  // npm install jwt-decode

class TokenManager {
  private api: Api;
  private refreshTimer: number | null = null;

  constructor(host: string) {
    this.api = new Api({ host, storage: localStorage });
  }

  // Schedule refresh before expiry
  scheduleRefresh(token: string) {
    const decoded = jwtDecode<{ exp: number }>(token);
    const expiresAt = decoded.exp * 1000;  // Convert to ms
    const refreshAt = expiresAt - 5 * 60 * 1000;  // 5 min before expiry
    const delay = refreshAt - Date.now();

    if (delay > 0) {
      this.refreshTimer = window.setTimeout(() => {
        this.promptRelogin();
      }, delay);
    }
  }

  private promptRelogin() {
    // Show modal asking user to re-authenticate
    // Or redirect to login with return URL
  }

  cleanup() {
    if (this.refreshTimer) {
      clearTimeout(this.refreshTimer);
    }
  }
}
```

## React Integration

### Session Provider

```tsx
import { createContext, useContext, useEffect, useState, ReactNode } from "react";
import { Api } from "bknd";

type User = { id: number; email: string; role?: string };

type SessionContextType = {
  user: User | null;
  isLoading: boolean;
  isAuthenticated: boolean;
  checkSession: () => Promise<User | null>;
  clearSession: () => void;
};

const SessionContext = createContext<SessionContextType | null>(null);

const api = new Api({
  host: "http://localhost:7654",
  storage: localStorage,
});

export function SessionProvider({ children }: { children: ReactNode }) {
  const [user, setUser] = useState<User | null>(null);
  const [isLoading, setIsLoading] = useState(true);

  // Check session on mount
  useEffect(() => {
    checkSession().finally(() => setIsLoading(false));
  }, []);

  async function checkSession() {
    const { ok, data } = await api.auth.me();
    const user = ok ? data?.user ?? null : null;
    setUser(user);
    return user;
  }

  function clearSession() {
    setUser(null);
    api.auth.logout();
  }

  return (
    <SessionContext.Provider
      value={{
        user,
        isLoading,
        isAuthenticated: user !== null,
        checkSession,
        clearSession,
      }}
    >
      {children}
    </SessionContext.Provider>
  );
}

export function useSession() {
  const context = useContext(SessionContext);
  if (!context) throw new Error("useSession must be used within SessionProvider");
  return context;
}
```

### Session-Aware Components

```tsx
import { useSession } from "./SessionProvider";

function Header() {
  const { user, isAuthenticated, clearSession } = useSession();

  if (!isAuthenticated) {
    return <a href="/login">Login</a>;
  }

  return (
    <div>
      <span>Welcome, {user!.email}</span>
      <button onClick={clearSession}>Logout</button>
    </div>
  );
}

function ProtectedPage() {
  const { isLoading, isAuthenticated } = useSession();

  if (isLoading) return <div>Checking session...</div>;
  if (!isAuthenticated) return <Navigate to="/login" />;

  return <div>Protected content</div>;
}
```

### Session Expiration Handler

```tsx
import { useEffect } from "react";
import { useSession } from "./SessionProvider";

function SessionExpirationHandler() {
  const { checkSession, clearSession } = useSession();

  useEffect(() => {
    // Check session periodically
    const interval = setInterval(async () => {
      const user = await checkSession();
      if (!user) {
        // Session expired
        alert("Your session has expired. Please log in again.");
        clearSession();
        window.location.href = "/login";
      }
    }, 5 * 60 * 1000);  // Every 5 minutes

    // Check on window focus (user returns to tab)
    const handleFocus = () => checkSession();
    window.addEventListener("focus", handleFocus);

    return () => {
      clearInterval(interval);
      window.removeEventListener("focus", handleFocus);
    };
  }, [checkSession, clearSession]);

  return null;  // Invisible component
}

// Add to app root
function App() {
  return (
    <SessionProvider>
      <SessionExpirationHandler />
      <Routes />
    </SessionProvider>
  );
}
```

## Server-Side Session Validation

### Validate Session in API Routes

```typescript
import { getApi } from "bknd";

export async function GET(request: Request, app: BkndApp) {
  const api = getApi(app);
  const user = await api.auth.resolveAuthFromRequest(request);

  if (!user) {
    return new Response("Unauthorized", { status: 401 });
  }

  // Session valid - user data available
  console.log("User ID:", user.id);
  console.log("Email:", user.email);
  console.log("Role:", user.role);

  return new Response(JSON.stringify({ user }));
}
```

### Server-Side Session Check (Next.js)

```typescript
// app/api/me/route.ts
import { getApp, getApi } from "bknd/adapter/nextjs";

export async function GET(request: Request) {
  const app = await getApp();
  const api = getApi(app);
  const user = await api.auth.resolveAuthFromRequest(request);

  if (!user) {
    return Response.json({ user: null }, { status: 401 });
  }

  return Response.json({ user });
}
```

## Common Patterns

### Remember Last Activity

```typescript
// Track user activity for session timeout warnings
let lastActivity = Date.now();

// Update on user interaction
document.addEventListener("click", () => (lastActivity = Date.now()));
document.addEventListener("keypress", () => (lastActivity = Date.now()));

// Check for inactivity
setInterval(() => {
  const inactiveMinutes = (Date.now() - lastActivity) / 1000 / 60;

  if (inactiveMinutes > 25) {
    // Warn user session will expire soon
    showSessionWarning();
  }

  if (inactiveMinutes > 30) {
    // Force logout
    api.auth.logout();
    window.location.href = "/login?reason=inactive";
  }
}, 60000);  // Check every minute
```

### Multi-Tab Session Sync

```typescript
// Sync session state across browser tabs
window.addEventListener("storage", async (event) => {
  if (event.key === "auth") {
    if (event.newValue === null) {
      // Logged out in another tab
      window.location.href = "/login";
    } else {
      // Logged in in another tab - refresh session
      await api.auth.me();
      window.location.reload();
    }
  }
});
```

### Secure Session Storage

```typescript
// For sensitive apps, use sessionStorage + warn on tab close
const api = new Api({
  host: "http://localhost:7654",
  storage: sessionStorage,
});

window.addEventListener("beforeunload", (e) => {
  if (api.auth.me()) {
    e.preventDefault();
    e.returnValue = "You will be logged out if you leave.";
  }
});
```

## Common Pitfalls

### Session Lost on Refresh

**Problem:** User logged out after page refresh

**Fix:** Provide storage adapter:

```typescript
// Wrong - no persistence
const api = new Api({ host: "http://localhost:7654" });

// Correct
const api = new Api({
  host: "http://localhost:7654",
  storage: localStorage,
});
```

### Cookie Not Working Locally

**Problem:** Cookie not set in development

**Fix:** Disable secure flag for localhost:

```typescript
{
  auth: {
    cookie: {
      secure: process.env.NODE_ENV === "production",  // false in dev
    },
  },
}
```

### Session Check Blocking UI

**Problem:** App shows blank while checking session

**Fix:** Show loading state:

```tsx
function App() {
  const { isLoading } = useSession();

  if (isLoading) {
    return <LoadingSpinner />;  // Don't leave blank
  }

  return <Routes />;
}
```

### Expired Token Still in Storage

**Problem:** Old token causes continuous 401 errors

**Fix:** Clear storage on auth failure:

```typescript
async function checkSession() {
  const { ok } = await api.auth.me();

  if (!ok) {
    // Clear stale token
    localStorage.removeItem("auth");
    return null;
  }

  return user;
}
```

## Verification

Test session handling:

**1. Session persists across refresh:**

```typescript
// Login
await api.auth.login("password", { email: "[email protected]", password: "pass" });

// Refresh page, then:
const { ok, data } = await api.auth.me();
console.log("Session persists:", ok && data?.user);  // Should be true
```

**2. Session expires correctly:**

```typescript
// Set short expiry in config (for testing)
jwt: { expires: 10 }  // 10 seconds

// Login, wait 15 seconds
await api.auth.login("password", { email, password });
await new Promise(r => setTimeout(r, 15000));

const { ok } = await api.auth.me();
console.log("Session expired:", !ok);  // Should be true
```

**3. Logout clears session:**

```typescript
await api.auth.logout();
const { ok } = await api.auth.me();
console.log("Session cleared:", !ok);  // Should be true
```

## DOs and DON'Ts

**DO:**

- Configure appropriate JWT expiry for your use case
- Use httpOnly cookies when possible (XSS protection)
- Check session validity on app initialization
- Handle session expiration gracefully with UI feedback
- Match cookie expiry with JWT expiry
- Use `secure: true` in production

**DON'T:**

- Store tokens in memory only (lost on refresh)
- Use long expiry times without renewal mechanism
- Ignore session expiration errors
- Mix cookie and header auth without clear reason
- Disable httpOnly unless absolutely necessary
- Forget to clear storage on logout

## Related Skills

- **bknd-setup-auth** - Configure authentication system
- **bknd-login-flow** - Login/logout functionality
- **bknd-oauth-setup** - OAuth/social login providers
- **bknd-protect-endpoint** - Secure specific endpoints
- **bknd-public-vs-auth** - Configure public vs authenticated access

Overview

This skill helps manage user sessions in Bknd applications by covering JWT lifecycle, session persistence, automatic renewal, and expiration handling. It explains cookie vs header transport, practical code patterns for frontend and server, and integration tips for React apps. Use it to implement reliable auth state checks, graceful expiration flows, and secure token handling.

How this skill works

It inspects how Bknd issues stateless JWTs on login, where tokens can be stored (httpOnly cookies, localStorage, sessionStorage, or memory), and how tokens are validated on each request. The skill describes cookie auto-renewal behavior, manual header-based refresh strategies, and patterns to detect and handle expired tokens. It also covers server-side request validation using Bknd APIs.

When to use it

  • Implementing session persistence and restore on app start
  • Choosing cookie vs header transport for security or manual control
  • Handling token expiration and prompting re-authentication
  • Auto-refreshing or periodically validating cookie-based sessions
  • Validating sessions server-side in API routes

Best practices

  • Prefer httpOnly, secure cookies for browsers to mitigate XSS when possible
  • Match cookie expiry with JWT expiry and enable cookie.renew for activity-based extension
  • Use localStorage or sessionStorage only when you need manual header transport; protect against XSS
  • Check session on app mount (api.auth.me()) and notify app state via a session manager or provider
  • Schedule proactive refresh for header tokens based on decoded exp, and show re-login UI before expiry

Example use cases

  • A React app that restores user on mount using a SessionProvider and api.auth.me()
  • Server API route that calls api.auth.resolveAuthFromRequest to gate endpoints
  • Single-page app using cookie transport to rely on auto-renew and avoid manual token handling
  • Header-based client that decodes JWT exp to schedule a re-authentication prompt
  • Background task that checks session on window focus and redirects to login if expired

FAQ

Should I store tokens in cookies or localStorage?

Use httpOnly secure cookies for best XSS protection and auto-renew behavior. Use localStorage/sessionStorage only when you need manual header-based control, and harden against XSS.

How do I detect an expired session?

Call api.auth.me() to re-validate. For header tokens, decode exp and schedule a refresh or prompt before expiry. Treat 401/Unauthorized responses as session expiration and trigger re-authentication.