Authentication & Authorization
Zoijs has no auth package, no <AuthProvider>, and no useAuth. It doesn't need them. Authentication and authorization are just data and patterns you already have the tools for: a resource for the current user, an action for login, a computed for roles, and a function for a protected route.
This guide gives you a single copy-paste auth.js built only from existing packages, plus the pages that use it. It is deliberately small — the goal is for you to read and own every line, not to install a black box.
The rule that matters most: the frontend can only hint at permissions to make the UI pleasant. Real authentication and authorization happen on the server. Every example here assumes the backend independently verifies the session and re-checks permissions on every request. Never trust the client.
Authentication vs authorization#
- Authentication answers "who are you?" — proving identity (signing in). The result is a session the server recognizes on future requests.
- Authorization answers "what may you do?" — whether an authenticated user can view a page or perform an action, usually by role or permission.
A user can be authenticated but not authorized for a given action. On the client, roles only show or hide UI; the server decides what actually runs.
The secure default: cookie / session#
Prefer this whenever you control the backend: on login the server sets an HttpOnly, Secure, SameSite cookie. The browser attaches it to every request automatically, and JavaScript cannot read it — code that can't read the credential can't leak it. Your app never sees the token; it just asks the server "who am I?" and gets back a user object or null.
// Any request that needs the session includes the cookie:
fetch("/api/me", { credentials: "include" });Cookies need CSRF protection. Because the browser sends the cookie automatically, useSameSite=Lax/Strictand a CSRF token for state-changing requests. This is enforced server-side.
Backend assumptions#
The recipe below assumes three placeholder endpoints. Yours will differ — change the URLs and the returned shape to match.
| Endpoint | Does |
|---|---|
GET /api/me | 200 { id, name, role } when signed in, 401 otherwise |
POST /api/login | validates credentials, sets the HttpOnly session cookie |
POST /api/logout | clears the session cookie |
The recipe — auth.js#
One small file you copy into your app and adjust. It composes @zoijs/resource, @zoijs/action, @zoijs/router, and computed() — no provider, no context, no global store.
// auth.js — a small auth recipe for Zoijs. NOT a framework: just existing
// packages composed into one file you own. The BACKEND enforces real security;
// this file only loads the current user, runs login/logout, and keeps the UI honest.
import { html, computed } from "@zoijs/core";
import { resource } from "@zoijs/resource";
import { action } from "@zoijs/action";
import { router } from "./router.js"; // your createRouter(...) instance
// --- Who am I? The source of truth (asks the server, never reads a cookie) ----
export const currentUser = resource(async () => {
const res = await fetch("/api/me", { credentials: "include" });
if (res.status === 401) return null; // not signed in
if (!res.ok) throw new Error("Could not load the current user");
return res.json(); // e.g. { id, name, role: "admin" }
});
// --- Derived auth state — UI hints only, never a security boundary ------------
export const isLoggedIn = computed(() => currentUser.data() != null);
export const isAdmin = computed(() => currentUser.data()?.role === "admin");
export const hasRole = (role) => currentUser.data()?.role === role;
// --- Login / logout. The cookie is set/cleared by the server -------------------
export const login = action(async (credentials) => {
const res = await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(credentials),
});
if (!res.ok) throw new Error("Invalid email or password");
await currentUser.refresh(); // re-load the now-authenticated user
});
export const logout = action(async () => {
await fetch("/api/logout", { method: "POST", credentials: "include" });
await currentUser.refresh(); // now resolves to null
});
// --- A safe redirect: never call router.go() during render --------------------
function redirect(to) {
queueMicrotask(() => router.go(to));
}
// --- Protected routes ---------------------------------------------------------
// Wrap any page. The check lives inside a reactive ${() => …} binding, so it
// waits for the user to load and reacts when it changes. While loading, show
// nothing; if not signed in (or not permitted), redirect; otherwise render.
export function protectedPage(Page, options = {}) {
const { redirectTo = "/login", canAccess } = options;
return () => html`
${() => {
if (currentUser.loading()) return html`<p>Loading…</p>`;
const user = currentUser.data();
if (!user) {
redirect(redirectTo);
return html`<p>Redirecting…</p>`;
}
if (canAccess && !canAccess(user)) {
redirect(redirectTo);
return html`<p>Redirecting…</p>`;
}
return Page(user);
}}
`;
}That's the whole "auth system": a resource, two actions, two computeds, and a guard function. You can read it top to bottom in a minute.
Protected routes#
Routes stay a plain object. Wrap the pages that need a session:
// router.js
import { createRouter } from "@zoijs/router";
import { Home } from "./pages/home.js";
import { LoginPage } from "./pages/login.js";
import { DashboardPage } from "./pages/dashboard.js";
import { AdminPage } from "./pages/admin.js";
import { protectedPage } from "./auth.js";
export const router = createRouter({
"/": Home,
"/login": LoginPage,
"/dashboard": protectedPage(DashboardPage, { redirectTo: "/login" }),
"/admin": protectedPage(AdminPage, {
redirectTo: "/login",
canAccess: (user) => user.role === "admin",
}),
});A protected route is a convenience, not a security boundary. It stops a casual user from seeing a page; anyone can still open DevTools and call your API directly. Each page's data endpoint must itself require authentication and authorization on the server.
Login page — @zoijs/forms + @zoijs/action#
@zoijs/forms holds the field state; the login action from auth.js performs the request. Validate on submit, then redirect on success.
import { html } from "@zoijs/core";
import { form } from "@zoijs/forms";
import { router } from "./router.js";
import { login } from "./auth.js";
export function LoginPage() {
const f = form({ email: "", password: "" });
const onSubmit = f.handleSubmit(async (values) => {
const ok = f.validate({
email: (v) => (v.includes("@") ? null : "Enter a valid email"),
password: (v) => (v ? null : "Password is required"),
});
if (!ok) return;
await login.run(values); // sets the cookie + refreshes currentUser
if (!login.error()) router.go("/dashboard");
});
return html`
<form onsubmit=${onSubmit}>
<label>Email
<input
name="email"
type="email"
value=${() => f.value("email")}
oninput=${(e) => f.set("email", e.target.value)}
onblur=${() => f.touch("email")} />
</label>
${() => f.error("email") && html`<p class="err">${f.error("email")}</p>`}
<label>Password
<input
name="password"
type="password"
value=${() => f.value("password")}
oninput=${(e) => f.set("password", e.target.value)} />
</label>
${() => f.error("password") && html`<p class="err">${f.error("password")}</p>`}
${() => login.error() && html`<p class="err">${login.error().message}</p>`}
<button type="submit" disabled=${() => login.pending()}>
${() => (login.pending() ? "Signing in…" : "Sign in")}
</button>
</form>
`;
}Role-based UI — computed()#
Derive authorization flags from the current user. They cache, stay reactive, and read cleanly in templates:
import { html, computed } from "@zoijs/core";
import { currentUser } from "./auth.js";
const canEditUsers = computed(() => currentUser.data()?.role === "admin");
export function AdminTools() {
return html`
${() => canEditUsers.get() && html`
<button onclick=${editUsers}>Edit users</button>
`}
`;
}This only hides UI. TheeditUsersendpoint must verify the caller is an admin on the server, every time. A hidden button is not a locked door, andisAdminin the browser can be flipped in DevTools in seconds.
Logout#
import { html } from "@zoijs/core";
import { router } from "./router.js";
import { logout } from "./auth.js";
export function LogoutButton() {
const onClick = async () => {
await logout.run(); // server clears the cookie; currentUser → null
router.go("/login");
};
return html`
<button onclick=${onClick} disabled=${() => logout.pending()}>
${() => (logout.pending() ? "Signing out…" : "Log out")}
</button>
`;
}The app shell ties it together — the router view reacts to navigation, and the current-user resource reacts to login/logout:
import { html, mount } from "@zoijs/core";
import { router } from "./router.js";
mount(() => html`<main>${() => router.view()}</main>`, "#app");Lightweight client state — @zoijs/storage#
Use @zoijs/storage for harmless UI hints only — things that would be no problem if a stranger read them:
import { storage } from "@zoijs/storage";
// ✅ Fine: preferences and convenience hints.
export const prefs = storage("ui-prefs", { theme: "light", lastEmail: "" });Token / JWT auth — and a serious warning#
Sometimes you talk to a cross-origin API that issues a JWT instead of a cookie. Keep a short-lived access token in memory only and send it as an Authorization header; re-obtain it on startup from a long-lived HttpOnly refresh cookie.
import { createState } from "@zoijs/core";
const accessToken = createState(null); // in memory only — gone on reload
export function authedFetch(url, options = {}) {
const token = accessToken.peek();
return fetch(url, {
...options,
headers: { ...options.headers, ...(token ? { Authorization: `Bearer ${token}` } : {}) },
});
}Never store a long-lived JWT inlocalStorageorsessionStorage.localStorageis plain text readable by any script on the page, so a single XSS bug means a stolen credential. Use@zoijs/storageonly for harmless UI preferences or short-lived, non-sensitive hints — never tokens, passwords, or session ids. The truth about who you are lives in theHttpOnlycookie and is confirmed by the current-user resource, not in storage.
Security warnings#
Read these as rules, not suggestions.
PreferHttpOnly,Secure,SameSitecookies. Code that can't read the credential can't leak it. This is the safest default for browser apps.
Client-side authorization is for UI only. Hiding a route or a button improves the experience; it does not protect anything. The server must independently authenticate the session and authorize the action on every request.
Never trust frontend role checks alone. Treat every incoming request as hostile and re-check permissions server-side.
Keep secrets and long-lived tokens off the client. API keys and signing secrets belong on the server; the browser bundle is public.
Always use HTTPS, set Secure on cookies, scope them tightly, and add CSRF protection for cookie-based, state-changing requests.What Zoijs intentionally does not provide#
By design, to stay small and un-framework-like, there is no @zoijs/auth, no <AuthProvider>, no useAuth, no global auth store, no token manager, no refresh-token engine, and no OAuth client. Authentication is backend-specific — a package would be either too opinionated or too configurable, and it would tempt you to treat the client as a security boundary. Instead, Zoijs gives you small parts you compose yourself:
| Concern | Tool | It's just… |
|---|---|---|
| Who am I? | @zoijs/resource | a module that fetches /api/me |
| Logging in | @zoijs/forms + @zoijs/action | a form and a POST |
| What can I do? | computed() | a derived boolean |
| Protected page | @zoijs/router | a function that checks the user |
| Lightweight hints | @zoijs/storage | non-sensitive prefs only |
That's the philosophy: lean on the platform and the small packages you already know. The server enforces the rules; the client just keeps the UI honest about them. See also the Security page for Zoijs's secure-by-default rendering.