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.
- Pros: Stateless, scalable, works well across services and domains.
- Cons: Hard to revoke before expiry, token storage on the client is a security concern, larger payload than a session ID.
- Best for: APIs, microservices, mobile backends, short-lived access tokens.
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.
- Pros: Easy to revoke, smaller client payload, secure when using HTTP-only cookies.
- Cons: Requires server-side storage, harder to scale across multiple servers without shared storage.
- Best for: Traditional web apps, dashboards, applications where revocation is important.
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.
- Pros: No password management, trusted providers, better UX for users who already have accounts.
- Cons: More complex setup, dependency on third-party availability, users must have an account with the provider.
- Best for: Consumer apps, developer tools, reducing signup friction.
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 cookieHere we're installing
josefor JWT signing and verification, andcookiefor 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.tswith 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.tsto 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 tohttp://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_secretCreating the Redirect Route
Create
src/routes/api/auth/github.tsto 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
localStorageif you can avoid it.localStorageis 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 uselocalStorage, 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
Secureflag 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
SameSitecookie attribute (set tolaxorstrict), 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-limitor 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.