Auth Providers (createAuth)
WebJs ships createAuth(), a NextAuth-style auth surface with OAuth providers, credentials login, and JWT sessions. Reach for this page when you want providers and sessions handled for you, and no external auth library in the dependency tree.
It is not the only route. If you want to own the session format, the password hashing, and the route-protection rules yourself, build on the framework primitives instead, which is what Build Your Own Authentication covers and what the blog example does. Pick createAuth() for OAuth and a batteries-included setup, pick the primitives when you want full control of the session.
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'))"