---
title: "Protect a route"
audience: "anyone with a page only signed-in visitors should see"
description: "You'll put a page behind a sign-in, send an anonymous visit to the sign-in page and read who the visitor is from a loader. The check is one file and it runs…"
snippet_caption: "app/middleware.ts"
snippet: "import type { MiddlewareCtx } from \"@snapfire/fsr/host\";\n\nexport async function middleware({ request, identity }: MiddlewareCtx) {\n  if (request.path.startsWith(\"/account\") && !identity) {\n    return Response.redirect(`/auth/login?return_to=${request.path}`, 303);\n  }\n}"
---

import { Install } from "@src/ui/Install";

You'll put a page behind a sign-in, send an anonymous visit to the sign-in page and read who the visitor is from a loader. The check is one file and it runs before any of your routes do.

<Install captured="0.x" />

## Session and identity

A **session** is the visitor's scratch space: a cart, a draft, a preference. Every visitor has one, signed in or not. You declare its shape in `app/schemas/session.ts` and a body reads and writes it as a plain object.

An **identity** is who the visitor is: a subject and a map of claims. A body reads `identity.subject` and `identity.claims.<name>`; it reads `null` when nobody is signed in. You never assign to it. It arrives on the session when a login finishes.

> Don't keep a `user_id` in the session yourself, since any code path could set it. Read `identity`, which only the login flow sets.

## Write the check in middleware

`app/middleware.ts` at the top of the app runs before every request that is not a static file: pages, the action route and handlers alike. It answers with a `Response` or with nothing.

```ts title="app/middleware.ts"
import type { MiddlewareCtx } from "@snapfire/fsr/host";

export async function middleware({ request, identity }: MiddlewareCtx) {
  if (request.path.startsWith("/account") && !identity) {
    return Response.redirect(`/auth/login?return_to=${request.path}`, 303);
  }
}
```

Returning nothing lets the request continue to its route. A `Response` of its own ends the request there, so an anonymous visit to `/account` is sent to `/auth/login`, which starts a sign-in and comes back to `return_to` when it finishes. Middleware can also `await next()` to run the route and change its `Response` before returning it.

Put the check in middleware instead of in each loader. A route you add under `/account` next month is then covered the day you add it. The action route is covered too, so a protected page's actions cannot be called by a visitor who was refused the page.

## Reading the identity in a loader

```ts title="app/routes/account/page.loader.ts"
import type { Ctx } from "@snapfire/fsr/host";

export async function load({ identity }: Ctx<"/account">) {
  return { name: identity?.subject ?? "" };
}
```

The middleware has already turned anonymous visits away, so `identity` is set here. A claim from the account, such as `identity.claims.role`, reads the same way.

The host carries the identity onto outbound service calls on its own, so a service that needs to know who is asking is already told. Pass `identity.subject` as an argument only to a method that takes it as data rather than as who the caller is.

## Prerendering

> `identity` is per request, so a route whose loader reads it renders per request. A page behind a sign-in can't be prerendered and the build won't try.

## Configure the login

Add an `[auth]` section to the configuration:

```toml title="config/app.toml"
[auth]
provider = "file"
login = "/sign-in"
```

That mounts four routes the framework owns, the way it owns the action route. `GET /auth/login` starts a sign-in, taking `return_to` from the query, else the `Referer`'s path, else `/`. The target is always a path on this origin. `/auth/callback` takes each answer, as a form post, a JSON post or a GET carrying an identity provider's query. A success is a 303 to where the sign-in began. A refusal is a 303 back to the login page with `error=denied`. `POST /auth/enrol` adds a method, such as a passkey, to whoever is signed in. `POST /auth/logout` verifies `_csrf` and clears identity and custody.

The `file` provider is `DevProvider` over `config/auth.toml` beside `app.toml`: a list of accounts with passwords and optional claims.

```toml title="config/auth.toml"
[[users]]
name = "alice"
password = "wonder"
claims = { role = "admin" }
```

It exists so the application can be written against identity from the first day and swap in a real provider later without a body changing, because a body only ever sees `identity`.

## Write the sign-in page

Auth never renders anything, so `[auth] login` names a route you write. For the `file` provider it is an ordinary form posting `user` and `password` to `/auth/callback`:

```ts title="app/routes/sign-in/page.loader.ts"
import type { Ctx } from "@snapfire/fsr/host";

export async function load({ query }: Ctx<"/sign-in">) {
  return { denied: query.error === "denied" };
}
```

```tsx title="app/routes/sign-in/page.tsx"
import type { SignInProps } from "@generated/client";

export default function SignIn({ denied }: SignInProps) {
  return (
    <section>
      <h1>Sign in</h1>
      {denied ? <p>That name and password did not match.</p> : null}
      <form method="post" action="/auth/callback">
        <label>
          Name <input name="user" autoComplete="username" />
        </label>
        <label>
          Password <input name="password" type="password" autoComplete="current-password" />
        </label>
        <button type="submit">Sign in</button>
      </form>
    </section>
  );
}
```

Run `fsr dev app` and open `/account`. You land on `/sign-in`. Sign in as `alice` with `wonder` and you are back on `/account`.

## Log out

Signing out is a form that posts to `/auth/logout` with the CSRF token. Put it on the account page, which reads `csrf_token` like any other prop:

```tsx title="app/routes/account/page.tsx"
import type { AccountProps } from "@generated/client";

export default function Account({ name, csrf_token }: AccountProps & { csrf_token?: string }) {
  return (
    <section>
      <h1>Signed in as {name}</h1>
      <form method="post" action="/auth/logout">
        <input type="hidden" name="_csrf" value={csrf_token ?? ""} />
        <button type="submit">Sign out</button>
      </form>
    </section>
  );
}
```

Clearing the identity clears the tokens held beside it, since the two cells persist together. Only the auth routes change the identity. A body can't set or clear it. Signing out sends you to `/`. Opening `/account` again sends you back to the sign-in page.

Next up: [355. Sign in with a passkey](https://snapfirers.com/fsr/learn/355-sign-in-with-a-passkey).
