Skip to content
qt

Advanced

Auth Middleware

The SSE endpoint is transport-agnostic — add authentication at the API route level using whatever strategy your app already uses.

Why Protect the SSE Endpoint?

The SSE stream delivers cache-invalidation events in real time. Any client that can open a connection will receive every event emitted to its scope. In a public or multi-tenant app that means:

  • Data leakage. Without auth, an anonymous request can subscribe and learn when data changes — even if it can't read the data itself.
  • Cross-tenant pollution. Without resolveScope, all clients share a single global scope. User A's invalidation triggers User B's refetches.
  • Control endpoint exposure. The same handler serves control routes (/pause, /resume, /tags/disable). An unprotected handler lets anyone halt cache invalidation system-wide.

The library does not bake in one auth strategy because every app is different — session cookies, JWT Bearer tokens, API keys, mutual TLS. Instead, both createAPIHandler and the framework adapters accept an authenticate callback where you plug in your existing auth logic.

Protecting the SSE Endpoint

Unprotected (default)
// routes/api.invalidator.$.ts
const handler = tagInvalidation.createAPIHandler({
  basePath: DEFAULT_INVALIDATOR_BASE_PATH,
});

async function handle({ request }: { request: Request }) {
  return handler(request);
}

export const Route = createFileRoute("/api/invalidator/$")({
  server: {
    handlers: {
      GET: handle,
      POST: handle,
    },
  },
});
With Auth Middleware
// routes/api.invalidator.$.ts
const handler = tagInvalidation.createAPIHandler({
  basePath: DEFAULT_INVALIDATOR_BASE_PATH,
});

async function handle({ request }: { request: Request }) {
  const session = await getSession(request);
  if (!session) {
    return new Response("Unauthorized", { status: 401 });
  }
  return handler(request);
}

export const Route = createFileRoute("/api/invalidator/$")({
  server: {
    handlers: {
      GET: handle,
      POST: handle,
    },
  },
});

The authenticate Callback

Both createAPIHandler and the framework adapters (createExpressMiddleware, createHonoMiddleware, …) accept the same options object:

  • basePath — the URL prefix to strip before routing (e.g. "/api/invalidator").
  • authenticate — called before every request. Return true to allow, or a Response (e.g. 401) to reject. Async is fully supported.

When no authenticate callback is provided the library logs a warning in development reminding you to protect the endpoint before shipping to production.

Pattern A — Session-Cookie Auth

Read the session cookie inside authenticate and reject unauthenticated requests before they reach the SSE stream. Works identically for TanStack Start routes and Node.js framework adapters — the callback always receives a web-standard Request.

// src/lib/tag-invalidation.server.ts
import { createTagInvalidationSystem } from "@tanstack-tools/query-tags/server";
import { getSession } from "@/lib/session"; // your session helper

export const tagInvalidation = createTagInvalidationSystem();

// Pass `authenticate` directly to createAPIHandler.
// Return `true` to allow, or a Response to reject.
export const invalidatorHandler = tagInvalidation.createAPIHandler({
  basePath: "/api/invalidator",
  authenticate: async (request: Request) => {
    const session = await getSession(request);
    if (!session?.userId) {
      return new Response("Unauthorized", { status: 401 });
    }
    return true;
  },
});

Pattern B — Bearer / JWT Auth

Extract the Authorization header, verify the token, and reject with 401 on failure. The authenticate callback is async, so you can await any async verify operation.

// src/lib/tag-invalidation.server.ts
import { createTagInvalidationSystem } from "@tanstack-tools/query-tags/server";
import { verifyJwt } from "@/lib/jwt"; // your JWT verify helper

export const tagInvalidation = createTagInvalidationSystem();

export const invalidatorHandler = tagInvalidation.createAPIHandler({
  basePath: "/api/invalidator",
  // authenticate receives the raw web-standard Request.
  // Return true to allow, or a Response to reject.
  authenticate: async (request: Request) => {
    const authHeader = request.headers.get("Authorization");
    const token = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null;
    if (!token) {
      return new Response("Unauthorized", { status: 401 });
    }
    const payload = await verifyJwt(token).catch(() => null);
    if (!payload) {
      return new Response("Unauthorized", { status: 401 });
    }
    return true;
  },
});

Pattern C — Per-Tenant Scope Isolation

Use resolveScope on createTagInvalidationSystem to route each invalidation event to a tenant-specific SSE channel. Even if authenticate is somehow bypassed, clients can only subscribe to their own scope — they will not receive events emitted to a different tenant's scopeKey.

Available scope kinds: { kind: 'global' }, { kind: 'user'; id: string }, { kind: 'tenant'; id: string }, { kind: 'custom'; key: string }.

// src/lib/tag-invalidation.server.ts
import { createTagInvalidationSystem } from "@tanstack-tools/query-tags/server";
import { getSession } from "@/lib/session";
import type { InvalidationScope } from "@tanstack-tools/query-tags";

// TContext is the shape you pass as the second arg to invalidateTags({ context }).
type AppContext = { tenantId: string };

export const tagInvalidation = createTagInvalidationSystem<AppContext>({
  // resolveScope maps your context to an InvalidationScope.
  // Available kinds: "global" | "user" | "tenant" | "custom"
  resolveScope: (context): InvalidationScope => {
    if (!context?.tenantId) return { kind: "global" };
    return { kind: "tenant", id: context.tenantId };
  },
});

// ── Protecting the SSE stream ────────────────────────────────────────────
// Each SSE client subscribes to the scope matching its ?scopeKey= query param.
// Guard the endpoint so a client can only subscribe to its own tenant's scope.
export const invalidatorHandler = tagInvalidation.createAPIHandler({
  basePath: "/api/invalidator",
  authenticate: async (request: Request) => {
    const session = await getSession(request);
    if (!session?.tenantId) {
      return new Response("Unauthorized", { status: 401 });
    }
    return true;
  },
});

// ── Server-side invalidation ─────────────────────────────────────────────
// Pass the context so resolveScope routes the event to the correct tenant.
// await tagInvalidation.invalidateTags({
//   tags: [appTags.invoices.list()],
//   context: { tenantId: session.tenantId },
// });

Token-Based Auth

For APIs using Bearer tokens, validate the token before forwarding to the handler.

async function handle({ request }: { request: Request }) {
  const token = request.headers.get("Authorization")?.replace("Bearer ", "");
  if (!token || !await verifyToken(token)) {
    return new Response("Unauthorized", { status: 401 });
  }
  return handler(request);
}

Scope-Based Isolation

Combine auth with scoping for defense-in-depth. Each authenticated user subscribes to their own scope, ensuring they only receive invalidation events for their data.

const tagInvalidation = createTagInvalidationSystem({
  resolveScope: (ctx) => ({
    kind: "user",
    id: ctx.userId,  // Each user gets isolated events
  }),
});

Control Endpoint Protection

The control endpoints (/pause, /resume, /tags/disable) modify system behavior. In production, restrict these to admin users or internal services.

async function handle({ request }: { request: Request }) {
  const session = await getSession(request);
  if (!session?.isAdmin) {
    return new Response("Forbidden", { status: 403 });
  }
  return handler(request);
}