Skip to content

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

ProviderEnv varsFlow
Credentials()NoneCustom authorize function, you handle password verification
Google()AUTH_GOOGLE_ID, AUTH_GOOGLE_SECRETOAuth 2.0 redirect flow
GitHub()AUTH_GITHUB_ID, AUTH_GITHUB_SECRETOAuth 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'))"