← Back to DevBytes

SolidJS Authentication: JWT, Sessions, and OAuth Integration

SolidJS Authentication: JWT, Sessions, and OAuth Integration

Authentication is one of the most critical concerns in modern web applications. Whether you're building a small dashboard or a large-scale SaaS product, you need a reliable way to verify who your users are and control what they can access. SolidJS, with its fine-grained reactivity and server-side rendering capabilities, is an excellent choice for building authenticated applications. In this tutorial, we'll explore three of the most common authentication strategies — JSON Web Tokens (JWT), server sessions, and OAuth — and show you how to implement each one in a SolidJS application.

Why Authentication Matters in SolidJS

SolidJS differs from frameworks like React in that it does not re-render components when state changes. Instead, it tracks dependencies at the signal level, updating only the parts of the DOM that depend on changed values. This makes authentication state particularly efficient: when a user logs in or out, only the UI elements that depend on the auth signal will update, not the entire component tree.

Because SolidStart (the official SolidJS meta-framework) supports both client-side navigation and server-side rendering, you also need to think carefully about where authentication state lives. A purely client-side approach may be fine for a single-page app, but for SSR or SEO-sensitive routes, you'll want server-validated authentication. Let's break down the three main approaches.

Understanding the Three Authentication Models

JWT (JSON Web Tokens)

JWT is a stateless authentication mechanism. The server issues a signed token containing claims (such as user ID and roles), and the client stores and sends it with each request. The server validates the signature without needing to look up a session in a database.

Server Sessions

With server sessions, the server creates a session record in a store (memory, Redis, or a database) and sends the client an opaque session ID, typically in an HTTP-only cookie. On each request, the server looks up the session by ID.

OAuth

OAuth is an authorization framework that lets users log in using a third-party provider like Google, GitHub, or Microsoft. Instead of managing passwords yourself, you redirect users to the provider, which returns an authorization code that your server exchanges for access tokens.

Setting Up the Project

Let's start by creating a SolidStart project. SolidStart gives us file-based routing, server functions, and API routes — all of which we'll use for authentication.

npx degit solidjs/templates/start my-auth-app
cd my-auth-app
npm install
npm install @solidjs/router jose cookie


Here we're installing jose for JWT signing and verification, and cookie for parsing cookies on the server. We'll use these throughout the tutorial.

Implementing JWT Authentication

Let's build a JWT-based authentication flow. We'll create a server function to handle login, a signal to hold the current user on the client, and a protected route that requires authentication.

Creating the Auth Utilities

First, create a file at src/lib/auth.ts with utilities for signing and verifying JWTs.

import { SignJWT, jwtVerify } from "jose";

const secret = new TextEncoder().encode(
  process.env.JWT_SECRET || "dev-secret-change-in-production"
);

export interface JWTPayload {
  userId: string;
  email: string;
  roles: string[];
}

export async function signToken(payload: JWTPayload): Promise<string> {
  return new SignJWT({ ...payload })
    .setProtectedHeader({ alg: "HS256" })
    .setIssuedAt()
    .setExpirationTime("2h")
    .sign(secret);
}

export async function verifyToken(token: string): Promise<JWTPayload | null> {
  try {
    const { payload } = await jwtVerify(token, secret);
    return payload as unknown as JWTPayload;
  } catch {
    return null;
  }
}


Creating the Auth Context

Next, create an auth context that provides the current user and login/logout functions. Create src/context/AuthContext.tsx.

import { createContext, useContext, createSignal, ParentComponent } from "solid-js";

interface User {
  userId: string;
  email: string;
  roles: string[];
}

interface AuthContextValue {
  user: () => User | null;
  login: (email: string, password: string) => Promise<boolean>;
  logout: () => Promise<void>;
}

const AuthContext = createContext<AuthContextValue>();

export const AuthProvider: ParentComponent = (props) => {
  const [user, setUser] = createSignal<User | null>(null);

  const login = async (email: string, password: string) => {
    const res = await fetch("/api/login", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ email, password }),
    });
    if (!res.ok) return false;
    const data = await res.json();
    localStorage.setItem("token", data.token);
    setUser(data.user);
    return true;
  };

  const logout = async () => {
    localStorage.removeItem("token");
    setUser(null);
  };

  return (
    <AuthContext.Provider value={{ user, login, logout }}>
      {props.children}
    </AuthContext.Provider>
  );
};

export function useAuth() {
  const ctx = useContext(AuthContext);
  if (!ctx) throw new Error("useAuth must be used within AuthProvider");
  return ctx;
}


Creating the Login API Route

Create src/routes/api/login.ts to handle the login request. In a real application, you'd verify the password against a hashed value in your database.

import { signToken } from "~/lib/auth";

const MOCK_USERS = [
  { id: "1", email: "user@example.com", password: "password123", roles: ["user"] },
  { id: "2", email: "admin@example.com", password: "admin123", roles: ["user", "admin"] },
];

export async function POST({ request }: { request: Request }) {
  const { email, password } = await request.json();
  const found = MOCK_USERS.find(
    (u) => u.email === email && u.password === password
  );
  if (!found) {
    return new Response(JSON.stringify({ error: "Invalid credentials" }), {
      status: 401,
      headers: { "Content-Type": "application/json" },
    });
  }
  const token = await signToken({
    userId: found.id,
    email: found.email,
    roles: found.roles,
  });
  return new Response(
    JSON.stringify({
      token,
      user: { userId: found.id, email: found.email, roles: found.roles },
    }),
    { headers: { "Content-Type": "application/json" } }
  );
}


Protecting Routes

Now let's create a protected route that checks for a valid token. Create src/routes/dashboard.tsx.

import { createSignal, Show, onMount } from "solid-js";
import { useAuth } from "~/context/AuthContext";
import { verifyToken } from "~/lib/auth";

export default function Dashboard() {
  const auth = useAuth();
  const [loading, setLoading] = createSignal(true);

  onMount(async () => {
    const token = localStorage.getItem("token");
    if (!token) {
      setLoading(false);
      return;
    }
    const res = await fetch("/api/me", {
      headers: { Authorization: `Bearer ${token}` },
    });
    if (res.ok) {
      const user = await res.json();
      auth.setUser(user);
    }
    setLoading(false);
  });

  return (
    <Show when={!loading()} fallback={<p>Loading...</p>}>
      <Show when={auth.user()} fallback={<p>Please log in.</p>}>
        <div>
          <h1>Dashboard</h1>
          <p>Welcome, {auth.user()!.email}</p>
          <button onClick={() => auth.logout()}>Log out</button>
        </div>
      </Show>
    </Show>
  );
}


Implementing Session-Based Authentication

Session-based authentication is often more secure for traditional web apps because the session ID lives in an HTTP-only cookie that JavaScript cannot access. Let's build a session-based flow using cookies.

Setting Up the Session Store

For this example, we'll use an in-memory Map. In production, you should use Redis or a database. Create src/lib/session.ts.

import { parse, serialize } from "cookie";

type Session = {
  userId: string;
  email: string;
  createdAt: number;
};

const sessions = new Map<string, Session>();

export function createSession(userId: string, email: string): string {
  const sessionId = crypto.randomUUID();
  sessions.set(sessionId, { userId, email, createdAt: Date.now() });
  return sessionId;
}

export function getSession(sessionId: string | undefined): Session | null {
  if (!sessionId) return null;
  return sessions.get(sessionId) || null;
}

export function destroySession(sessionId: string | undefined): void {
  if (sessionId) sessions.delete(sessionId);
}

export function parseCookies(request: Request): Record<string, string> {
  const cookieHeader = request.headers.get("Cookie");
  return cookieHeader ? parse(cookieHeader) : {};
}

export function setSessionCookie(sessionId: string): string {
  return serialize("session_id", sessionId, {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    sameSite: "lax",
    path: "/",
    maxAge: 60 * 60 * 24 * 7, // 7 days
  });
}

export function clearSessionCookie(): string {
  return serialize("session_id", "", {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    sameSite: "lax",
    path: "/",
    maxAge: 0,
  });
}


Session Login and Logout API Routes

Create src/routes/api/session-login.ts.

import {
  createSession,
  setSessionCookie,
} from "~/lib/session";

const MOCK_USERS = [
  { id: "1", email: "user@example.com", password: "password123" },
];

export async function POST({ request }: { request: Request }) {
  const { email, password } = await request.json();
  const found = MOCK_USERS.find(
    (u) => u.email === email && u.password === password
  );
  if (!found) {
    return new Response(JSON.stringify({ error: "Invalid credentials" }), {
      status: 401,
      headers: { "Content-Type": "application/json" },
    });
  }
  const sessionId = createSession(found.id, found.email);
  return new Response(JSON.stringify({ user: { email: found.email } }), {
    headers: {
      "Content-Type": "application/json",
      "Set-Cookie": setSessionCookie(sessionId),
    },
  });
}


Create src/routes/api/session-logout.ts.

import {
  parseCookies,
  destroySession,
  clearSessionCookie,
} from "~/lib/session";

export async function POST({ request }: { request: Request }) {
  const cookies = parseCookies(request);
  destroySession(cookies.session_id);
  return new Response(JSON.stringify({ success: true }), {
    headers: {
      "Content-Type": "application/json",
      "Set-Cookie": clearSessionCookie(),
    },
  });
}


Reading the Session on the Server

With SolidStart server functions, you can read the session directly on the server. Create src/lib/server.ts.

import { getRequestEvent } from "solid-js/web";
import { parseCookies, getSession } from "~/lib/session";

export async function getCurrentUser() {
  const event = getRequestEvent();
  if (!event) return null;
  const cookies = parseCookies(event.request);
  const session = getSession(cookies.session_id);
  if (!session) return null;
  return { userId: session.userId, email: session.email };
}


This function can be called from any server function or server-rendered route to get the authenticated user without any client-side token management.

Integrating OAuth with GitHub

OAuth adds a third party into the mix. We'll implement GitHub OAuth, which is a common choice for developer-facing apps. The flow involves redirecting the user to GitHub, receiving an authorization code, and exchanging it for an access token.

Configuring the OAuth App

First, register a new OAuth app at https://github.com/settings/developers. Set the callback URL to http://localhost:3000/api/auth/callback. You'll receive a client ID and client secret. Store them in your environment variables.

# .env
GITHUB_CLIENT_ID=your_client_id
GITHUB_CLIENT_SECRET=your_client_secret


Creating the Redirect Route

Create src/routes/api/auth/github.ts to redirect users to GitHub's authorization page.

const GITHUB_AUTH_URL = "https://github.com/login/oauth/authorize";

export async function GET() {
  const params = new URLSearchParams({
    client_id: process.env.GITHUB_CLIENT_ID!,
    redirect_uri: "http://localhost:3000/api/auth/callback",
    scope: "read:user user:email",
    state: crypto.randomUUID(),
  });
  return new Response(null, {
    status: 302,
    headers: {
      Location: `${GITHUB_AUTH_URL}?${params.toString()}`,
    },
  });
}


Handling the Callback

Create src/routes/api/auth/callback.ts. This route receives the authorization code, exchanges it for an access token, fetches the user profile, and creates a session.

import { createSession, setSessionCookie } from "~/lib/session";

export async function GET({ request }: { request: Request }) {
  const url = new URL(request.url);
  const code = url.searchParams.get("code");
  if (!code) {
    return new Response("Missing code", { status: 400 });
  }

  // Exchange code for access token
  const tokenRes = await fetch("https://github.com/login/oauth/access_token", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Accept: "application/json",
    },
    body: JSON.stringify({
      client_id: process.env.GITHUB_CLIENT_ID,
      client_secret: process.env.GITHUB_CLIENT_SECRET,
      code,
      redirect_uri: "http://localhost:3000/api/auth/callback",
    }),
  });
  const tokenData = await tokenRes.json();
  if (tokenData.error) {
    return new Response(tokenData.error_description, { status: 400 });
  }

  // Fetch user profile
  const userRes = await fetch("https://api.github.com/user", {
    headers: {
      Authorization: `Bearer ${tokenData.access_token}`,
      Accept: "application/vnd.github.v3+json",
    },
  });
  const githubUser = await userRes.json();

  // Create a session for the OAuth user
  const sessionId = createSession(String(githubUser.id), githubUser.login);
  return new Response(null, {
    status: 302,
    headers: {
      Location: "/dashboard",
      "Set-Cookie": setSessionCookie(sessionId),
    },
  });
}


Adding the Login Button

On your login page, add a button that links to the GitHub OAuth route.

export default function Login() {
  return (
    <div>
      <h1>Sign In</h1>
      <a href="/api/auth/github">
        <button>Sign in with GitHub</button>
      </a>
    </div>
  );
}


Best Practices

Token Storage

Never store JWTs in localStorage if you can avoid it. localStorage is accessible to any JavaScript running on the page, making it vulnerable to cross-site scripting (XSS) attacks. Prefer HTTP-only cookies for storing tokens or session IDs. If you must use localStorage, keep access tokens short-lived and use a refresh token mechanism.

Use HTTPS Everywhere

Always serve your application over HTTPS in production. Cookies should have the Secure flag set so they are only transmitted over encrypted connections. This prevents man-in-the-middle attacks from intercepting session IDs or tokens.

Implement CSRF Protection for Cookie-Based Auth

When using cookies for authentication, you are vulnerable to cross-site request forgery (CSRF). Mitigate this by using the SameSite cookie attribute (set to lax or strict), and consider implementing anti-CSRF tokens for state-changing requests.

Validate on the Server

Never trust client-side authentication state alone. Always validate the token or session on the server before returning protected data. Client-side checks are for UX (hiding or showing UI), not for security.

Use Short-Lived Tokens with Refresh Tokens

If you use JWTs, keep access tokens short-lived (15–30 minutes) and issue a longer-lived refresh token. The refresh token is used to obtain new access tokens without requiring the user to log in again. Store refresh tokens in HTTP-only cookies.

Hash Passwords Properly

Never store plaintext passwords. Use a modern hashing algorithm like bcrypt, scrypt, or Argon2. These algorithms are designed to be slow, making brute-force attacks computationally expensive.

import bcrypt from "bcryptjs";

const hashedPassword = await bcrypt.hash(password, 10);
const isValid = await bcrypt.compare(inputPassword, hashedPassword);


Rate Limit Login Endpoints

Protect your login and registration endpoints with rate limiting to prevent brute-force attacks. Tools like express-rate-limit or a reverse proxy like Cloudflare can help.

Log Authentication Events

Keep an audit log of login attempts, successful logins, and password changes. This helps you detect suspicious activity and respond to security incidents.

Conclusion

Authentication in SolidJS can be approached in several ways, each with its own trade-offs. JWT works well for stateless APIs and distributed systems, server sessions offer simplicity and easy revocation for traditional web apps, and OAuth lets you delegate identity to trusted providers. The right choice depends on your application's architecture, security requirements, and user experience goals. By combining SolidJS's fine-grained reactivity with solid server-side validation, proper cookie handling, and established security best practices, you can build authentication flows that are both performant and secure. Start with the approach that fits your use case today, and remember that authentication is never a one-time setup — it requires ongoing attention to security updates, token rotation, and monitoring as your application grows.

— Ad —

Google AdSense will appear here after approval

← Back to all articles