React + backend auth lifecycle
A React frontend signs a user in, mints a session token, and calls a backend that verifies it. Node and Rust both shown, end to end.
The most common Wacht backend shape: React on the frontend, your server verifying session tokens, protected handlers, tenancy-scoped data access. The browser never holds a backend credential. It mints a short-lived JSON Web Token (JWT) per call; your backend verifies it against your deployment's JSON Web Key Set (JWKS). Backend examples are shown for both Node (@wacht/backend) and Rust (wacht with the axum feature).
The seam to get right: the frontend sends Authorization: Bearer <token>, the backend verifies the signature and reads user_id / organization_id / workspace_id from the claims, and every tenant query filters on those claims. Frontend hiding is UX, not a security boundary.
Prerequisites
- A Wacht deployment, its publishable key (
pk_...), and a backend API key (wk_...) - Node 20+ and pnpm for the frontend (and the Node backend, if you pick it)
- 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. Same hooks and components; only the provider's router binding differs.
Wrap your app
DeploymentProvider fetches deployment config and holds session state. It takes publicKey (the pk_... publishable key, which encodes your Frontend API host). The adapter packages build the router binding for you.
// src/main.tsx
import { DeploymentProvider } from "@wacht/react-router";
function App() {
return (
<DeploymentProvider publicKey={import.meta.env.VITE_WACHT_PUBLISHABLE_KEY}>
<Router />
</DeploymentProvider>
);
}Sign-in UI
SignedIn / SignedOut render their children based on session state. UserButton is the prebuilt account menu. NavigateToSignIn redirects to the hosted sign-in page on mount — use it when you don't embed SignInForm directly.
import { SignedIn, SignedOut, UserButton, NavigateToSignIn } from "@wacht/react-router";
function Header() {
return (
<header>
<SignedOut>
<NavigateToSignIn />
</SignedOut>
<SignedIn>
<UserButton />
</SignedIn>
</header>
);
}Call your backend
useSession().getToken() mints a session JWT and caches it in memory until just before it expires. Send it as a bearer header. There's no shared session store between frontend and backend — the token is the entire handoff.
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(template?) takes an optional JWT template name and returns the signed token string; with no argument it uses the default template. The token's claims carry sub (user id) plus the active organization and workspace. It refreshes automatically — a cached token is reused until expiry, then re-minted. getToken is only available once the session has loaded; during loading, the hook's fields aren't callable, so gate on useSession().loading first.
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
Two credentials do two jobs. The API key (apiKey / WACHT_API_KEY) authenticates your server when it calls Wacht's REST API. The publishable key (WACHT_PUBLISHABLE_KEY) is how the verifier derives your Frontend API host to fetch JWKS — it verifies the user's token. Don't confuse them: the API key is a secret and never ships to the browser; the publishable key is public.
// src/server.ts
import { initClient } from "@wacht/backend";
initClient({
apiKey: process.env.WACHT_API_KEY!,
});initClient() configures the global SDK client once at startup. The users.* and ai.* REST clients read from it. Request verification (getAuth, authenticateRequest) is separate: it reads WACHT_PUBLISHABLE_KEY from the environment to find the JWKS endpoint, or you pass publishableKey per call in options. In non-Node runtimes without process.env (Workers, edge), pass publishableKey explicitly.
// 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_API_KEY plus WACHT_PUBLISHABLE_KEY (or WACHT_FRONTEND_HOST), then fetches and caches your deployment's JWKS so AuthLayer can verify signatures offline. Run it before building the router — AuthLayer::new() panics if the signing material isn't loaded yet; use AuthLayer::try_new() if you'd rather handle that as None. AuthLayer verifies the bearer token on every request and attaches an AuthContext to request extensions. A missing or invalid token doesn't reject at the layer — it leaves the extension empty, so optional-auth routes still work. The extractor in your handler decides whether to reject.
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 your deployment's JWKS, and returns a WachtAuth object. auth.protect() is async — await it. It throws WachtAuthError (with code: "unauthenticated", status 401) if the user isn't signed in; map that to a 401 in your framework's error handler. auth.userId / auth.organizationId / auth.workspaceId are null when unauthenticated.
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 rejects with 401 Unauthorized if AuthLayer didn't find a valid token. It derefs to the AuthContext, so inside the handler you have auth.user_id and auth.session_id (both String), auth.organization_id and auth.workspace_id (both Option<String>), and auth.claims for the raw token 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(),
}
}OptionalAuth never rejects. It wraps Option<AuthContext>, so match on auth.0.
Tenancy scoping
Wacht's business-to-business (B2B) model attaches the user's active organization and workspace IDs to the token claims. Protected handlers read those claims — never a tenant id from the request body — and scope every query to them.
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 the active organization or workspace via useSession().switchOrganization(orgId) and switchWorkspace(workspaceId). Switching clears the cached token, so the next getToken() carries the new IDs. Read the current selection with useActiveOrganization() and useActiveWorkspace(). Because the switch invalidates the token, an in-flight request can still arrive at the backend with the old scope — keep the backend the authority and the frontend in step, not ahead.
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 axum::extract::Path;
use axum::http::StatusCode;
use wacht::middleware::{Permission, PermissionScope, RequireAuth, RequirePermission};
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.as_deref().unwrap(), &member_id).await?;
Ok(StatusCode::NO_CONTENT)
}Define the permission as a type implementing Permission with its scope, then add RequirePermission<ManageMembers> to the handler signature. If the user lacks it, the request gets 403 Forbidden before the handler body runs. Keep the permission types in one module so reviewers can audit them in one place.
Calling Wacht's API from the backend
Verifying the token tells you who's calling. To read or mutate Wacht state — the full user record, organization details — use the REST client, which authenticates with your API key. Verification and the REST client are independent: one reads the user's token, the other carries your server's key.
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);
}users.getUser(id) returns a UserDetails record. It runs against the global client configured by initClient().
use wacht::middleware::RequireAuth;
use wacht::models::UserDetails;
async fn whoami(auth: RequireAuth) -> Result<Json<UserDetails>, Error> {
let user = wacht::try_get_client()?
.users()
.fetch_user_details(&auth.user_id)
.send()
.await?;
Ok(Json(user))
}fetch_user_details(id) returns UserDetails. try_get_client() hands back the client init_from_env() set up; it errors if init never ran.
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_PUBLISHABLE_KEY=pk_...
VITE_API_URL=http://localhost:3000Backend .env:
WACHT_API_KEY=wk_...
WACHT_PUBLISHABLE_KEY=pk_...The API key authenticates your server's REST calls and is a secret. The publishable key is how both SDKs derive the Frontend API host to verify user tokens — the host is base64-encoded inside the pk_ key, so no separate URL is needed. Rust accepts WACHT_FRONTEND_HOST as an explicit alternative to WACHT_PUBLISHABLE_KEY.
Production checklist
- All protected routes
await auth.protect()(Node) or useRequireAuth/RequirePermission(Rust). No reliance on frontend hiding to gate data. - All tenant queries filter by
auth.organizationId/auth.workspaceId(Node) orauth.organization_id/auth.workspace_id(Rust). No "current user's data" query 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 API key. Only the publishable key ships to the browser.
- The frontend retries a 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.