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/typesUse @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 response | Likely cause | Frontend action |
|---|---|---|
401 Unauthorized | Token missing, expired, or invalid signature | Trigger sign-in, then retry once |
403 Forbidden | Authenticated but lacks the required permission | Show "you don't have access" UI; do not retry |
404 Not Found on a tenant resource | Workspace switch happened mid-flight | Refetch 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:3000Backend .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 useRequireAuth/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:
AuthLayeris 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
Permissiontypes (Rust) live in one module so reviewers can audit them.
Next
- B2B org/workspace lifecycle for the org/workspace UX patterns.
- Reference: Frontend API and Backend API for exact request/response shapes.