Skip to main content

Chapter 28: Authentication

Authentication verifies who the user is. This chapter covers session-based authentication with Effect services, secure password handling, and OAuth integration patterns.

Authentication Architecture​

User → Login Form → API → AuthService.login() → Session created → Token returned
↓
User → Subsequent requests → Bearer token → AuthService.verify() → Session valid → Allow

Session-Based vs. Token-Based​

We recommend session-based auth with a token reference:

  • Server stores session data in the database
  • Client stores an opaque session token
  • Token is sent as Authorization: Bearer <token> header
  • Server looks up the session on each request

This is more secure than pure JWTs because sessions can be revoked server-side.

Auth Service​

// features/auth/services/auth-service.ts
import { Context, Effect, Layer, Data } from "effect";
import * as Crypto from "node:crypto";
import { PrismaClient } from "@/shared/services/prisma-service";

// Errors
export class InvalidCredentialsError extends Data.TaggedError("InvalidCredentialsError")<{}> {}
export class SessionExpiredError extends Data.TaggedError("SessionExpiredError")<{}> {}
export class EmailAlreadyRegisteredError extends Data.TaggedError("EmailAlreadyRegisteredError")<{
readonly email: string;
}> {}

// Service interface
export class AuthService extends Context.Tag("AuthService")<
AuthService,
{
readonly register: (input: RegisterInput) => Effect.Effect<
{ user: User; token: string },
EmailAlreadyRegisteredError | DatabaseError
>;
readonly login: (input: LoginInput) => Effect.Effect<
{ user: User; token: string },
InvalidCredentialsError | DatabaseError
>;
readonly verifySession: (token: string) => Effect.Effect<
{ user: User; session: Session },
SessionExpiredError | DatabaseError
>;
readonly logout: (token: string) => Effect.Effect<void, DatabaseError>;
readonly logoutAllSessions: (userId: string) => Effect.Effect<void, DatabaseError>;
}
>() {
static Live = Layer.effect(
this,
Effect.gen(function* () {
const prisma = yield* PrismaClient;

return {
register: (input) =>
Effect.gen(function* () {
// Check if email is taken
const existing = yield* Effect.tryPromise({
try: () => prisma.user.findUnique({ where: { email: input.email } }),
catch: (e) => new DatabaseError({ operation: "user.findByEmail", cause: e }),
});

if (existing) {
yield* new EmailAlreadyRegisteredError({ email: input.email });
}

// Hash password
const passwordHash = yield* hashPassword(input.password);

// Create user
const user = yield* Effect.tryPromise({
try: () => prisma.user.create({
data: {
email: input.email,
name: input.name,
passwordHash,
},
}),
catch: (e) => new DatabaseError({ operation: "user.create", cause: e }),
});

// Create session
const token = generateSessionToken();
yield* Effect.tryPromise({
try: () => prisma.session.create({
data: {
userId: user.id,
token,
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days
},
}),
catch: (e) => new DatabaseError({ operation: "session.create", cause: e }),
});

return { user: sanitizeUser(user), token };
}),

login: (input) =>
Effect.gen(function* () {
const user = yield* Effect.tryPromise({
try: () => prisma.user.findUnique({ where: { email: input.email } }),
catch: (e) => new DatabaseError({ operation: "user.findByEmail", cause: e }),
});

if (!user) {
yield* new InvalidCredentialsError();
}

const isValid = yield* verifyPassword(input.password, user!.passwordHash);
if (!isValid) {
yield* new InvalidCredentialsError();
}

const token = generateSessionToken();
yield* Effect.tryPromise({
try: () => prisma.session.create({
data: {
userId: user!.id,
token,
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
},
}),
catch: (e) => new DatabaseError({ operation: "session.create", cause: e }),
});

return { user: sanitizeUser(user!), token };
}),

verifySession: (token) =>
Effect.gen(function* () {
const session = yield* Effect.tryPromise({
try: () => prisma.session.findUnique({
where: { token },
include: { user: true },
}),
catch: (e) => new DatabaseError({ operation: "session.verify", cause: e }),
});

if (!session || session.expiresAt < new Date()) {
// Clean up expired session if it exists
if (session) {
yield* Effect.tryPromise({
try: () => prisma.session.delete({ where: { id: session.id } }),
catch: () => void 0,
}).pipe(Effect.ignore);
}
yield* new SessionExpiredError();
}

return {
user: sanitizeUser(session!.user),
session: session!,
};
}),

logout: (token) =>
Effect.tryPromise({
try: () => prisma.session.deleteMany({ where: { token } }),
catch: (e) => new DatabaseError({ operation: "session.logout", cause: e }),
}).pipe(Effect.asVoid),

logoutAllSessions: (userId) =>
Effect.tryPromise({
try: () => prisma.session.deleteMany({ where: { userId } }),
catch: (e) => new DatabaseError({ operation: "session.logoutAll", cause: e }),
}).pipe(Effect.asVoid),
};
})
);
}

// Helper functions
function generateSessionToken(): string {
return Crypto.randomBytes(32).toString("hex");
}

function sanitizeUser(user: any): User {
const { passwordHash, ...safe } = user;
return safe;
}

Password Hashing​

// shared/lib/password.ts
import { Effect, Data } from "effect";
import * as Crypto from "node:crypto";

class PasswordHashError extends Data.TaggedError("PasswordHashError")<{
readonly cause: unknown;
}> {}

// Using scrypt (built into Node.js, no external dependency)
export const hashPassword = (password: string): Effect.Effect<string, PasswordHashError> =>
Effect.async<string, PasswordHashError>((resume) => {
const salt = Crypto.randomBytes(16).toString("hex");
Crypto.scrypt(password, salt, 64, (err, derivedKey) => {
if (err) {
resume(Effect.fail(new PasswordHashError({ cause: err })));
} else {
resume(Effect.succeed(`${salt}:${derivedKey.toString("hex")}`));
}
});
});

export const verifyPassword = (
password: string,
hash: string
): Effect.Effect<boolean, PasswordHashError> =>
Effect.async<boolean, PasswordHashError>((resume) => {
const [salt, key] = hash.split(":");
Crypto.scrypt(password, salt, 64, (err, derivedKey) => {
if (err) {
resume(Effect.fail(new PasswordHashError({ cause: err })));
} else {
resume(Effect.succeed(
Crypto.timingSafeEqual(Buffer.from(key, "hex"), derivedKey)
));
}
});
});

Frontend Auth Integration​

// features/auth/hooks/use-auth.ts
import { useAuthStore } from "../stores/auth-store";
import { useQueryClient, useMutation } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";

export function useAuth() {
const authStore = useAuthStore();
const queryClient = useQueryClient();
const navigate = useNavigate();

const loginMutation = useMutation({
mutationFn: async (credentials: { email: string; password: string }) => {
const response = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(credentials),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error?.message ?? "Login failed");
}
return response.json();
},
onSuccess: ({ data }) => {
authStore.setAuth(data.user, data.token);
queryClient.invalidateQueries();
navigate({ to: "/dashboard" });
},
});

const logout = () => {
const token = authStore.token;
if (token) {
fetch("/api/auth/logout", {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
}).catch(() => {}); // Best effort
}
authStore.clearAuth();
queryClient.clear();
navigate({ to: "/login" });
};

return {
user: authStore.user,
isAuthenticated: authStore.isAuthenticated,
login: loginMutation.mutate,
loginError: loginMutation.error?.message,
isLoggingIn: loginMutation.isPending,
logout,
};
}

Route Protection​

// src/routes/_authenticated.tsx
export const Route = createFileRoute("/_authenticated")({
beforeLoad: async () => {
const { token } = useAuthStore.getState();
if (!token) {
throw redirect({
to: "/login",
search: { redirect: window.location.pathname },
});
}
},
component: AuthenticatedLayout,
});

Summary​

  • ✅ Session-based auth with revocable server-side sessions
  • ✅ Effect service for register, login, verify, and logout operations
  • ✅ Secure password hashing with scrypt (Node.js built-in)
  • ✅ Timing-safe comparison prevents timing attacks
  • ✅ Frontend integration with Zustand store and TanStack Query
  • ✅ Route protection in TanStack Router's beforeLoad