Authentication
WebJs provides NextAuth-style authentication with OAuth providers, credentials login, and JWT sessions. No external auth library needed.
Setup
// lib/auth.server.ts: create once
import { createAuth, Credentials, Google, GitHub } from '@webjsdev/server';
import { db } from '#db/connection.server.ts';
export const { auth, signIn, signOut, handlers } = createAuth({
providers: [
Credentials({
async authorize(credentials) {
const user = await db.query.users.findFirst({
where: { email: credentials.email }
});
if (!user || !verifyPassword(credentials.password, user.passwordHash)) {
return null;
}
return { id: user.id, name: user.name, email: user.email };
},
}),
Google(), // reads AUTH_GOOGLE_ID, AUTH_GOOGLE_SECRET from env
GitHub(), // reads AUTH_GITHUB_ID, AUTH_GITHUB_SECRET from env
],
secret: process.env.AUTH_SECRET,
});
Mount the auth API route
// app/api/auth/[...path]/route.ts
import { handlers } from '#lib/auth.server.ts';
export const GET = handlers.GET;
export const POST = handlers.POST;
Read the session
// In any page or server action:
import { auth } from '#lib/auth.server.ts';
export default async function Dashboard() {
const session = await auth();
if (!session) throw redirect('/login');
return html`<h1>Welcome, ${session.user.name}</h1>`;
}
A redirect() thrown during a GET page render (an auth gate like this) defaults to 302 Found, the conventional bounce code. The same redirect() thrown from a server action (a POST) defaults to the method-preserving 307 instead; an explicit redirect(url, status) overrides either. To type session.user so session.user.name needs no cast, see Typing the auth() Session User.
Sign in and sign out
// Server actions
import { signIn, signOut } from '#lib/auth.server.ts';
export async function login(credentials) {
return signIn('credentials', credentials);
}
export async function loginWithGoogle() {
return signIn('google', {}, { redirectTo: '/dashboard' });
}
export async function logout() {
return signOut({ redirectTo: '/' });
}
signOut is also reachable as a route: the mounted handlers serve POST /api/auth/signout, so a plain <form method="POST" action="/api/auth/signout"> logs a user out with no JavaScript. That is how the scaffold's auth gallery card renders its logout button.
Showing a failed sign-in
A failed credentials sign-in redirects to ${pages.error}?error=CredentialsSignin, falling back to the home page when pages.error is unset (which silently swallows the failure). Point the error page at your login route, then read the code and render a message:
createAuth({
// ...providers, secret
pages: { error: '/login' }, // a failed sign-in returns here
});
// app/login/page.ts
export default function LoginPage({ searchParams }) {
const failed = searchParams.error === 'CredentialsSignin';
return html`
${failed ? html`<p role="alert">Invalid email or password.</p>` : ''}
<form method="POST" action="/api/auth/signin/credentials">...</form>
`;
}
The scaffold's auth gallery card wires exactly this, so a wrong password shows a message on the login page instead of bouncing to the landing page with no feedback.
Callbacks
Customize the session and JWT with callbacks:
createAuth({
// ...providers
callbacks: {
async jwt({ token, user }) {
// Add custom fields to the JWT
if (user) {
token.sub = user.id;
token.role = user.role;
}
return token;
},
async session({ session, token }) {
// Expose custom fields to auth()
session.user.id = token.sub;
session.user.role = token.role;
return session;
},
},
});
Type the session
By default auth() resolves { user: Record<string, unknown> }, so reading a custom field your callbacks set (like session.user.id) needs a cast and a typo slips past TypeScript. Opt into a typed user one of two ways.
Augment AuthUser (NextAuth/Auth.js style) to type every auth() call across the app. Declare the fields your session/jwt callbacks set:
// lib/auth.server.ts (or any .d.ts in the project)
declare module '@webjsdev/server' {
interface AuthUser {
id: string;
role: 'admin' | 'member';
}
}
Now session.user.id is string everywhere, and a misspelling like session.user.idd is a compile error.
Or parameterise the factory with createAuth<TUser>() to type just this instance, without a global augmentation:
interface AppUser {
id: string;
role: 'admin' | 'member';
}
export const { auth, signIn, signOut, handlers } =
createAuth<AppUser>({ /* ...providers, secret */ });
const session = await auth();
session?.user.role; // typed as 'admin' | 'member', no cast
Both are types-only and opt-in: leave them out and auth().user stays the loose Record<string, unknown>, so existing code keeps working unchanged. The fields you declare should match what your callbacks actually write onto session.user.
Providers
| Provider | Env vars | Flow |
|---|---|---|
Credentials() | None | Custom authorize function, you handle password verification |
Google() | AUTH_GOOGLE_ID, AUTH_GOOGLE_SECRET | OAuth 2.0 redirect flow |
GitHub() | AUTH_GITHUB_ID, AUTH_GITHUB_SECRET | OAuth 2.0 redirect flow |
Session strategies
JWT (default): Session data signed in a cookie. Stateless and scales horizontally without Redis. Cannot be revoked before expiry.
Database: Session ID in cookie, data in cache store. Can revoke sessions instantly. Requires Redis or similar for horizontal scaling.
createAuth({
session: { strategy: 'database' }, // default: 'jwt'
// ...
});
Environment variables
AUTH_SECRET=your-random-secret-32-chars-minimum AUTH_GOOGLE_ID=your-google-oauth-client-id AUTH_GOOGLE_SECRET=your-google-oauth-client-secret AUTH_GITHUB_ID=your-github-oauth-client-id AUTH_GITHUB_SECRET=your-github-oauth-client-secret
Generate a secret: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"