Encodera

Thanks for reading!

Donate to the site — we work hard for you.

Contents

Logging in and out

Authentication is checking who the user is. After a successful login their id is written into the session from the previous lesson. Then any loader or action can read the session and decide whether to let the visitor in. In the blog only a logged-in author can write posts.

Checking the password

Create app/auth.server.ts. Passwords are stored only as hashes; here the built-in node:crypto module does the hashing:

// app/auth.server.ts
import { scryptSync, timingSafeEqual } from "node:crypto";
import { redirect } from "react-router";
import { getSession } from "./sessions.server";
 
type User = { id: string; email: string; passwordHash: string; salt: string };
 
// In a real project users live in a users table.
const users: User[] = [];
 
export function hashPassword(password: string, salt: string) {
  return scryptSync(password, salt, 32).toString("hex");
}
 
export async function verifyLogin(email: string, password: string) {
  const user = users.find((u) => u.email === email);
  if (!user) return null;
  const hash = Buffer.from(hashPassword(password, user.salt), "hex");
  return timingSafeEqual(hash, Buffer.from(user.passwordHash, "hex")) ? user : null;
}
 
export async function requireUserId(request: Request) {
  const session = await getSession(request.headers.get("Cookie"));
  const userId = session.get("userId");
  if (!userId) throw redirect("/login");
  return userId;
}

requireUserId either returns the id or throws a redirect to /login, and the loader stops right there.

The login page

Add the route route("login", "routes/login.tsx"):

// app/routes/login.tsx
import { Form, data, redirect } from "react-router";
import type { Route } from "./+types/login";
import { verifyLogin } from "../auth.server";
import { commitSession, getSession } from "../sessions.server";
 
export async function action({ request }: Route.ActionArgs) {
  const form = await request.formData();
  const user = await verifyLogin(String(form.get("email")), String(form.get("password")));
  if (!user) {
    return data({ error: "Wrong email or password" }, { status: 400 });
  }
 
  const session = await getSession(request.headers.get("Cookie"));
  session.set("userId", user.id);
  return redirect("/", { headers: { "Set-Cookie": await commitSession(session) } });
}
 
export default function Login({ actionData }: Route.ComponentProps) {
  return (
    <Form method="post">
      <input name="email" type="email" placeholder="anna@example.com" />
      <input name="password" type="password" />
      {actionData?.error && <p>{actionData.error}</p>}
      <button type="submit">Log in</button>
    </Form>
  );
}

With correct credentials the user lands on the home page logged in; with wrong ones they see an error message.

Protecting a page

In the loader and action of app/routes/new-post.tsx call the check on the first line:

// app/routes/new-post.tsx
import { requireUserId } from "../auth.server";
 
export async function loader({ request }: Route.LoaderArgs) {
  await requireUserId(request);
  return null;
}

A guest who opens /posts/new is redirected to /login. Add the same line at the start of the action.

Logging out

Logging out is an action that destroys the session. The route route("logout", "routes/logout.tsx"):

// app/routes/logout.tsx
import { redirect } from "react-router";
import type { Route } from "./+types/logout";
import { destroySession, getSession } from "../sessions.server";
 
export async function action({ request }: Route.ActionArgs) {
  const session = await getSession(request.headers.get("Cookie"));
  return redirect("/", { headers: { "Set-Cookie": await destroySession(session) } });
}

Put a form in the header: Form method="post" action="/logout" with a "Log out" button. Logout uses POST rather than a link so that prefetching or someone else's image cannot trigger it.

Common mistake

Protecting only the loader and leaving the action open. Hiding the form from guests is not enough: a POST request to /posts/new can be sent without the form. Call requireUserId in every action that changes data.

Thanks for reading!

Donate to the site — we work hard for you.