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