Skip to content

Build Your Own Authentication

This page is the hand-rolled route: session-based auth built on the framework primitives, where you own the session format, the password hashing, and the route-protection rules. The blog example is a complete implementation of it, using scrypt password hashing, session tokens in cookies, and middleware-based route protection.

WebJs does ship a batteries-included option, so this is a choice rather than a necessity. Auth Providers (createAuth) gives you OAuth providers, credentials login, and JWT sessions out of the box. Take that page if you want OAuth or do not want to own the session. Stay here if you want full control over how a session is minted, stored, and checked.

Architecture

lib/
  password.ts   : hashPassword() / verifyPassword() via scrypt
  session.ts    : createSession() / destroySession() / getUserByToken()
                  plus cookie header helpers
modules/auth/
  actions/
    signup.server.ts   : register and create session
    login.server.ts    : verify credentials and create session
    logout.server.ts   : destroy session
  queries/
    current-user.server.ts  : read user from request cookies
app/
  api/auth/
    signup/route.ts    : POST handler, sets Set-Cookie
    login/route.ts     : POST handler, sets Set-Cookie
    logout/route.ts    : POST handler, clears cookie
    middleware.ts      : rate limit on auth endpoints
  dashboard/
    middleware.ts      : require auth, redirect to /login

Password Hashing

// lib/password.server.ts
import { scrypt, randomBytes, timingSafeEqual } from 'node:crypto';
import { promisify } from 'node:util';

const scryptAsync = promisify(scrypt);

export async function hashPassword(password: string): Promise<string> {
  const salt = randomBytes(16);
  const derived = await scryptAsync(password, salt, 64) as Buffer;
  return `scrypt$${salt.toString('hex')}$${derived.toString('hex')}`;
}

export async function verifyPassword(
  password: string,
  stored: string | null,
): Promise<boolean> {
  if (!stored) return false;
  const [, saltHex, hashHex] = stored.split('$');
  const salt = Buffer.from(saltHex, 'hex');
  const expected = Buffer.from(hashHex, 'hex');
  const derived = await scryptAsync(password, salt, expected.length) as Buffer;
  return timingSafeEqual(expected, derived);
}

Session Cookies

// lib/session.server.ts
import { db } from '#db/connection.server.ts';
import { sessions } from '#db/schema.server.ts';

export const SESSION_COOKIE = 'my_session';

export async function createSession(userId: number) {
  const token = randomBytes(16).toString('hex');
  const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
  await db.insert(sessions).values({ token, userId, expiresAt });
  return { token, expiresAt };
}

export function sessionCookieHeader(token: string, opts = {}) {
  return `${SESSION_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000`;
}

Reading the Current User

// modules/auth/queries/current-user.server.ts
'use server';
import { cookies } from '@webjsdev/server';
import { getUserByToken, SESSION_COOKIE } from '#lib/session.server.ts';

export async function currentUser() {
  const token = cookies().get(SESSION_COOKIE);
  return getUserByToken(token);
}

The cookies() helper from @webjsdev/server reads the in-flight Request via AsyncLocalStorage, so no parameter passing needed.

Route Protection via Middleware

// app/dashboard/middleware.ts
import { cookies } from '@webjsdev/server';
import { getUserByToken, SESSION_COOKIE } from '#lib/session.server.ts';

export default async function requireAuth(
  req: Request,
  next: () => Promise<Response>,
) {
  const user = await getUserByToken(cookies().get(SESSION_COOKIE));
  if (!user) {
    const to = encodeURIComponent(new URL(req.url).pathname);
    return new Response(null, {
      status: 302,
      headers: { location: `/login?then=${to}` },
    });
  }
  return next();
}

This middleware only fires for routes under /dashboard/**. Unauthenticated users are redirected to /login with a return URL.

Rate Limiting Auth Endpoints

// app/api/auth/middleware.ts
import { rateLimit } from '@webjsdev/server';

export default rateLimit({ window: '10s', max: 5 });

Any request to /api/auth/** is rate-limited to 5 per 10 seconds per IP. This applies to signup, login, and logout equally.

CSRF Protection

Server actions are automatically CSRF-protected by a cross-origin check, whether called via the auto-generated RPC stub or submitted through a bound <form>: the server reads Sec-Fetch-Site (with an Origin-vs-host fallback for older browsers), and rejects a cross-site request with a 403 unless its origin is in webjs.allowedOrigins. No token or cookie is involved. See Security.

API routes (route.ts) are NOT automatically protected, since they're intended for external consumers. Authenticate a mutating route handler yourself (bearer token, API key, or an explicit origin check in middleware).

Login Form Component

The blog's <auth-forms> component demonstrates a tabbed login/signup form that POSTs to the API routes, receives a Set-Cookie session header, and redirects to the dashboard. See modules/auth/components/auth-forms.ts in the blog example for the complete implementation.