Spoosh
Guides

Authentication

Managing tokens, headers, and auth flows

This guide covers common authentication patterns with Spoosh.

Static Headers

Set a static token when creating the client:

const spoosh = new Spoosh<ApiSchema, Error>("/api", {
  headers: {
    Authorization: "Bearer my-static-token",
  },
}).use([cachePlugin({ staleTime: 5000 }), invalidationPlugin()]);

Dynamic Headers

The headers option accepts a function that runs before each request. Use this to read tokens from localStorage or state:

const spoosh = new Spoosh<ApiSchema, Error>("/api", {
  headers: () => {
    const token = localStorage.getItem("accessToken");

    return token ? { Authorization: `Bearer ${token}` } : {};
  },
}).use([cachePlugin({ staleTime: 5000 }), invalidationPlugin()]);

Async Headers

The function can also be async — useful when tokens need to be fetched or decrypted:

const spoosh = new Spoosh<ApiSchema, Error>("/api", {
  headers: async () => {
    const token = await getTokenFromSecureStorage();

    return token ? { Authorization: `Bearer ${token}` } : {};
  },
}).use([cachePlugin({ staleTime: 5000 }), invalidationPlugin()]);

For cookie-based auth (session cookies, HttpOnly cookies), set credentials: "include":

const spoosh = new Spoosh<ApiSchema, Error>("/api", {
  credentials: "include",
}).use([cachePlugin({ staleTime: 5000 }), invalidationPlugin()]);

Cookies are automatically sent with every request. No manual header management needed.

Token Refresh

Check if the access token is expired before each request. If expired, refresh it and update localStorage.

You can use any HTTP client for the refresh call. This example uses createClient for type-safe API calls:

import { SpooshPlugin, createClient } from "@spoosh/core";

// Define your API schema
type ApiSchema = {
  "auth/refresh": {
    POST: {
      data: { accessToken: string; refreshToken: string };
      body: { refreshToken: string };
    };
  };
  // ... other endpoints
};

function isTokenExpired(token: string): boolean {
  try {
    const payload = JSON.parse(atob(token.split(".")[1]));
    return payload.exp * 1000 < Date.now();
  } catch {
    return true;
  }
}

const api = createClient<ApiSchema>("/api");

async function refreshAccessToken(): Promise<string | null> {
  const refreshToken = localStorage.getItem("refreshToken");

  if (!refreshToken) return null;

  const { data } = await api("auth/refresh").POST({
    body: { refreshToken },
  });

  if (!data) return null;

  localStorage.setItem("accessToken", data.accessToken);
  localStorage.setItem("refreshToken", data.refreshToken);

  return data.accessToken;
}

function tokenRefreshPlugin(): SpooshPlugin {
  // Shared promise to prevent multiple concurrent refresh calls.
  // When several requests detect an expired token at the same time,
  // they all await the same promise instead of each triggering a refresh.
  let refreshPromise: Promise<string | null> | null = null;

  return {
    name: "token-refresh",
    operations: ["read", "write", "pages"],
    // Priority -20: Run before cache plugin (-10) to ensure tokens are refreshed
    // before checking cache, preventing stale token issues
    priority: -20,
    middleware: async (context, next) => {
      const t = context.tracer?.("token-refresh");
      let token = localStorage.getItem("accessToken");

      if (token && isTokenExpired(token)) {
        if (!refreshPromise) {
          t?.log("Token expired, refreshing", { color: "yellow" });
          refreshPromise = refreshAccessToken().finally(() => {
            refreshPromise = null;
          });
        }

        token = await refreshPromise;
      }

      if (token) {
        context.setHeaders({ Authorization: `Bearer ${token}` });
      }

      return next();
    },
  };
}

The t variable is a tracer that emits events visible in devtool. It's optional — if devtool isn't installed, context.tracer is undefined.

Use it in your client:

const spoosh = new Spoosh<ApiSchema, Error>("/api").use([
  tokenRefreshPlugin(), // priority: -20 (runs first)
  cachePlugin({ staleTime: 5000 }), // priority: -10 (runs second)
  invalidationPlugin(), // priority: 0 (default)
]);

Note: Plugins are sorted by priority (lower runs first). For plugins with the same priority, registration order is preserved. Since these plugins have different priorities (-20, -10, 0), their execution order is guaranteed regardless of registration order.

Handling Unauthorized Responses

Use afterResponse to globally handle 403 (forbidden) errors — for example, redirecting to login:

function authErrorPlugin(): SpooshPlugin {
  return {
    name: "auth-error",
    operations: ["read", "write", "pages"],
    afterResponse: (context, response) => {
      if (response.status === 403) {
        localStorage.removeItem("accessToken");
        localStorage.removeItem("refreshToken");
        window.location.href = "/login";
      }
    },
  };
}
const spoosh = new Spoosh<ApiSchema, Error>("/api").use([
  tokenRefreshPlugin(), // priority: -20 (runs first to refresh tokens)
  authErrorPlugin(), // priority: 0 (default, runs after cache)
  cachePlugin({ staleTime: 5000 }), // priority: -10 (runs second)
  invalidationPlugin(), // priority: 0 (default)
]);

Unlike middleware, afterResponse runs after every response — even when other plugins return early from cache. See Middleware for more details.

Clearing State on Logout

Combine clearCache and invalidate("*") to wipe all cached data:

import { create } from "@spoosh/react";

const { useWrite, clearCache } = create(spoosh);

function LogoutButton() {
  const { trigger } = useWrite((api) => api("auth/logout").POST());

  const handleLogout = async () => {
    await trigger({ clearCache: true, invalidate: "*" });
    localStorage.removeItem("accessToken");
    localStorage.removeItem("refreshToken");
    window.location.href = "/login";
  };

  return <button onClick={handleLogout}>Logout</button>;
}

For scenarios where you don't have a logout endpoint, use the instance clearCache directly:

function LogoutButton() {
  const handleLogout = () => {
    clearCache();
    localStorage.removeItem("accessToken");
    localStorage.removeItem("refreshToken");
    window.location.href = "/login";
  };

  return <button onClick={handleLogout}>Logout</button>;
}

Per-Request Headers

Override headers for a specific request:

const { trigger } = useWrite((api) => api("admin/action").POST());

await trigger({
  body: { action: "reset" },
  headers: { Authorization: "Bearer admin-token" },
});

On this page