NewWacht Bench is live — AI-assisted development for Wacht
GuidesIntegration Playbooks

React + backend auth lifecycle

A React frontend that signs users in and calls a backend that verifies them. Both Node and Rust on the server. End to end with code.

A working setup for the most common Wacht backend shape: React on the frontend, your backend verifying session tokens, protected handlers, tenancy-scoped data access. Backend examples are shown for both Node (@wacht/backend) and Rust (wacht with the axum feature).

Prerequisites

  • A Wacht deployment with the publishable key and a backend secret
  • Node 20+ and pnpm for the frontend (and Node backend, if you pick that)
  • Rust 1.75+ and cargo (if you pick the Rust backend)

Frontend

Install

pnpm add @wacht/react-router @wacht/types

Use @wacht/nextjs instead if you're on Next.js, or @wacht/tanstack-router for TanStack. The patterns are the same.

Wrap your app

// src/main.tsx
import { WachtProvider } from "@wacht/react-router";

function App() {
  return (
    <WachtProvider publicKey={import.meta.env.VITE_WACHT_PUBLIC_KEY}>
      <Router />
    </WachtProvider>
  );
}

Sign-in UI

Drop in the prebuilt components or build your own with the hooks.

import { SignIn, SignedIn, SignedOut, UserButton } from "@wacht/react-router";

function Header() {
  return (
    <header>
      <SignedOut>
        <SignIn />
      </SignedOut>
      <SignedIn>
        <UserButton />
      </SignedIn>
    </header>
  );
}

Call your backend

Get the session token from useSession and send it as a bearer.

import { useSession } from "@wacht/react-router";

function useApi() {
  const { getToken } = useSession();

  return async function call(path: string, init?: RequestInit) {
    const token = await getToken();
    return fetch(`${import.meta.env.VITE_API_URL}${path}`, {
      ...init,
      headers: {
        ...(init?.headers ?? {}),
        authorization: `Bearer ${token}`,
      },
    });
  };
}

getToken() returns a JWT signed by Wacht's keys. It refreshes automatically before expiry. Your backend verifies it; no shared session storage between frontend and backend.

Backend

Install

pnpm add @wacht/backend
# Cargo.toml
[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
wacht = { version = "0.6", features = ["axum"] }

The axum feature enables AuthLayer and the extractors.

Configure the client

// src/server.ts
import { initBackend } from "@wacht/backend";

initBackend({
  publishableKey: process.env.WACHT_PUBLISHABLE_KEY!,
  secretKey: process.env.WACHT_BACKEND_SECRET!,
});

initBackend() configures the SDK once at startup. After that the helpers (getAuth, authenticateRequest, plus the ai.* and users.* clients) read from process state.

// src/main.rs
use wacht::init_from_env;

#[tokio::main]
async fn main() {
    init_from_env().await.expect("wacht init failed");

    let app = axum::Router::new()
        .route("/me", axum::routing::get(me))
        .layer(wacht::middleware::AuthLayer::new());

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

init_from_env() reads WACHT_DEPLOYMENT_URL and WACHT_BACKEND_SECRET. AuthLayer verifies the bearer token on every request and attaches an AuthContext to request extensions. If the token is missing or invalid, the layer doesn't reject — it leaves the extension empty. Reject in the handler via the extractor so optional-auth routes still work.

A protected handler

import { getAuth } from "@wacht/backend";

export async function me(request: Request): Promise<Response> {
  const auth = await getAuth(request);
  await auth.protect(); // throws 401 if not authenticated

  return Response.json({
    user_id: auth.userId,
    organization_id: auth.organizationId,
    workspace_id: auth.workspaceId,
  });
}

getAuth reads the Authorization: Bearer ... header, verifies the JWT against Wacht's JWKS, and returns a WachtAuth object. auth.protect() throws WachtAuthError if the user isn't signed in; map that to a 401 in your framework's error handler.

use axum::response::IntoResponse;
use wacht::middleware::RequireAuth;

async fn me(auth: RequireAuth) -> impl IntoResponse {
    axum::Json(serde_json::json!({
        "user_id": auth.user_id,
        "organization_id": auth.organization_id,
        "workspace_id": auth.workspace_id,
    }))
}

RequireAuth returns 401 Unauthorized if AuthLayer didn't find a valid token. Inside the handler you have auth.user_id, auth.organization_id, auth.workspace_id, and the raw claims.

Optional auth

import { getAuth } from "@wacht/backend";

export async function publicOrPersonalized(request: Request): Promise<Response> {
  const auth = await getAuth(request);
  if (auth.userId) {
    return new Response(`hello ${auth.userId}`);
  }
  return new Response("hello stranger");
}

Don't call auth.protect() and the handler stays open. auth.userId is null when no valid token was provided.

use wacht::middleware::OptionalAuth;

async fn public_or_personalized(auth: OptionalAuth) -> impl IntoResponse {
    match auth.0 {
        Some(ctx) => format!("hello {}", ctx.user_id),
        None => "hello stranger".into(),
    }
}

Tenancy scoping

Wacht's B2B model attaches the user's active organization and workspace IDs to the token. Protected handlers check them before touching tenant data.

import { getAuth } from "@wacht/backend";

export async function listDocuments(request: Request) {
  const auth = await getAuth(request);
  await auth.protect();

  const workspaceId = auth.workspaceId;
  if (!workspaceId) {
    return new Response("workspace required", { status: 403 });
  }

  const docs = await db
    .selectFrom("documents")
    .where("workspace_id", "=", workspaceId)
    .selectAll()
    .execute();

  return Response.json(docs);
}
use wacht::middleware::RequireAuth;

async fn list_documents(auth: RequireAuth) -> Result<Json<Vec<Document>>, Error> {
    let workspace_id = auth
        .workspace_id
        .ok_or(Error::Forbidden("workspace required"))?;

    let docs = sqlx::query_as!(
        Document,
        "SELECT * FROM documents WHERE workspace_id = $1",
        workspace_id
    )
    .fetch_all(&pool)
    .await?;

    Ok(Json(docs))
}

The frontend switches active organization/workspace via useActiveOrganization() and useActiveWorkspace() from the SDK. The next token they get will carry the new IDs.

Permission checks

If you have organization roles:

import { getAuth } from "@wacht/backend";

export async function removeMember(request: Request, memberId: string) {
  const auth = await getAuth(request);
  await auth.protect({ permission: "members:manage" });

  // permission check already passed when we got here
  await deleteMember(db, auth.organizationId!, memberId);
  return new Response(null, { status: 204 });
}

auth.protect({ permission }) throws when the user lacks the permission. Use auth.has({ permission }) for non-throwing checks (e.g. to hide UI affordances).

use wacht::middleware::{Permission, RequirePermission, auth::PermissionScope};

struct ManageMembers;
impl Permission for ManageMembers {
    const PERMISSION: &'static str = "members:manage";
    const SCOPE: PermissionScope = PermissionScope::Organization;
}

async fn remove_member(
    _perm: RequirePermission<ManageMembers>,
    auth: RequireAuth,
    Path(member_id): Path<String>,
) -> Result<StatusCode, Error> {
    delete_member(&pool, auth.organization_id.unwrap(), &member_id).await?;
    Ok(StatusCode::NO_CONTENT)
}

If the user doesn't have the permission, the request gets 403 Forbidden before your handler runs. Permission types live in one module so reviewers can audit them.

Calling Wacht's API from the backend

Once authenticated, use the SDK to read or mutate Wacht state on the user's behalf.

import { getAuth, users } from "@wacht/backend";

export async function whoami(request: Request) {
  const auth = await getAuth(request);
  await auth.protect();

  const user = await users.getUser(auth.userId!);
  return Response.json(user);
}
use wacht::middleware::RequireAuth;

async fn whoami(auth: RequireAuth) -> Result<Json<User>, Error> {
    let user = wacht::try_get_client()?
        .users()
        .get_user_by_id(&auth.user_id)
        .send()
        .await?;
    Ok(Json(user))
}

For agent-runtime work (creating tasks, looking up actors), see the Agents guide.

Error handling

Map backend auth failures to explicit frontend states so the user sees the right thing.

Backend responseLikely causeFrontend action
401 UnauthorizedToken missing, expired, or invalid signatureTrigger sign-in, then retry once
403 ForbiddenAuthenticated but lacks the required permissionShow "you don't have access" UI; do not retry
404 Not Found on a tenant resourceWorkspace switch happened mid-flightRefetch with the new workspace context

Most apps wrap their fetch helper to handle 401 by silently refreshing the token via getToken() and retrying once before bouncing the user to sign-in.

Environment

Frontend .env:

VITE_WACHT_PUBLIC_KEY=pk_...
VITE_API_URL=http://localhost:3000

Backend .env:

WACHT_DEPLOYMENT_URL=https://your-deployment.wacht.dev
WACHT_PUBLISHABLE_KEY=pk_...
WACHT_BACKEND_SECRET=sk_...

The publishable key is only required on the Node side for getAuth to derive the JWKS endpoint. Rust reads JWKS from the deployment URL directly.

Production checklist

  • All protected routes call auth.protect() (Node) or use RequireAuth / RequirePermission (Rust). No reliance on frontend hiding to gate data.
  • All tenant queries filter by auth.organizationId / auth.workspaceId. No "current user's data" without an explicit scope check.
  • Rust: AuthLayer is mounted on the protected router, not the public one. Public routes (health checks, OAuth callbacks) bypass it.
  • The frontend never holds the backend secret. Only the publishable key ships to the browser.
  • The frontend retries 401 once after a fresh getToken() before bouncing to sign-in.
  • Permission strings (Node) or Permission types (Rust) live in one module so reviewers can audit them.

Next

On this page