# Authentication

Course: [Astro](https://www.devtutorials.ai/learn/astro) · Section: Server Development
Source: https://www.devtutorials.ai/learn/astro/server/authentication

> Authentication vs authorization, how login works, session-based login and logout actions, hashing passwords, protecting pages and endpoints in middleware, why path matching is unsafe, roles and ownership, 401 vs 403, and auth libraries.

## Authentication

- **Authentication** = checking **who** someone is. Usually with an email and a password.
- **Authorization** = checking **what** they may do. For example, only admins may delete posts.
- In this lesson, you will put together everything from this chapter:
  - **actions** for the login form,
  - **sessions** to remember the user,
  - **middleware** to protect pages.

**Simple lesson:** authentication asks "who are you?", authorization asks "are you allowed?".

### A Real-World Example

- Asha wants a private dashboard where she drafts posts.
- Only she should see it. Ravi, her co-writer, may edit only his own drafts.
- She adds a login page, stores the logged-in user in the session, and lets middleware guard `/dashboard`.

Press **▶ Run**. Try `/dashboard` first: you are sent to the login page. Log in with **asha@example.com** and **astro123**.

{result?.error && <p class="err">{result.error.message}</p>}
<form method="POST" action={actions.login}>
    <input name="email" value="asha@example.com" />
    <input name="password" type="password" value="astro123" />
    <button>Log in</button>
</form>
`,
    "src/pages/dashboard.astro": `---

const result = Astro.getActionResult(actions.logout);
if (result && !result.error) return Astro.redirect("/login");
const user = Astro.locals.user;
---

<h1>Welcome back, {user?.name}</h1>
<p>Your role: {user?.role}</p>
<form method="POST" action={actions.logout}><button>Log out</button></form>
`,
  }}
/>

What happened:

- Middleware read the user from the session. With no user, it redirected `/dashboard` to `/login`.
- The login action checked the password **hash**, regenerated the session, and stored the user.
- Log out destroyed the session, so `/dashboard` is closed again.

### What You Will Learn

In this lesson, you will learn:

1. The difference between authentication and authorization.
2. How a login works: identity, session and cookie.
3. How to store passwords safely.
4. How to build login and logout actions.
5. How to protect pages and endpoints in middleware.
6. How to check roles and ownership.
7. When to answer 401, and when 403.
8. When to use an auth library instead.

## Authentication Basics

### How Login Works

<figure className="ast-fig ngc-fig">
<svg viewBox="0 0 660 210" role="img" aria-labelledby="fig-login" style={{ width: "100%", maxWidth: 660, height: "auto" }}>
  <title id="fig-login">Animation: four steps. The visitor sends email and password. The server checks the hash. The server stores the user in a session. The browser keeps only the session cookie for later requests.</title>
  <g style={{ fontFamily: "var(--sans)", fontSize: 12, fontWeight: 700 }}>
    <rect x="10" y="60" width="140" height="70" rx="10" style={{ fill: "var(--ngc-blue-soft)", stroke: "var(--ngc-blue)" }}><animate attributeName="stroke-width" values="4;1;1;1;4" keyTimes="0;0.24;0.25;0.99;1" dur="8s" repeatCount="indefinite" /></rect>
    <text x="80" y="90" textAnchor="middle" style={{ fill: "var(--ngc-blue)" }}>1. Send email</text>
    <text x="80" y="108" textAnchor="middle" style={{ fill: "var(--ngc-blue)" }}>+ password</text>
    <rect x="175" y="60" width="140" height="70" rx="10" style={{ fill: "var(--ngc-orange-soft)", stroke: "var(--ngc-orange)" }}><animate attributeName="stroke-width" values="1;1;4;1;1" keyTimes="0;0.25;0.26;0.5;1" dur="8s" repeatCount="indefinite" /></rect>
    <text x="245" y="90" textAnchor="middle" style={{ fill: "var(--ngc-orange)" }}>2. Check the</text>
    <text x="245" y="108" textAnchor="middle" style={{ fill: "var(--ngc-orange)" }}>password hash</text>
    <rect x="340" y="60" width="140" height="70" rx="10" style={{ fill: "var(--ngc-purple-soft)", stroke: "var(--ngc-purple)" }}><animate attributeName="stroke-width" values="1;1;4;1;1" keyTimes="0;0.5;0.51;0.75;1" dur="8s" repeatCount="indefinite" /></rect>
    <text x="410" y="90" textAnchor="middle" style={{ fill: "var(--ngc-purple)" }}>3. regenerate()</text>
    <text x="410" y="108" textAnchor="middle" style={{ fill: "var(--ngc-purple)" }}>+ store user</text>
    <rect x="505" y="60" width="145" height="70" rx="10" style={{ fill: "var(--ngc-green-soft)", stroke: "var(--ngc-green)" }}><animate attributeName="stroke-width" values="1;1;4;1" keyTimes="0;0.75;0.76;1" dur="8s" repeatCount="indefinite" /></rect>
    <text x="577" y="90" textAnchor="middle" style={{ fill: "var(--ngc-green)" }}>4. Cookie with</text>
    <text x="577" y="108" textAnchor="middle" style={{ fill: "var(--ngc-green)" }}>session ID only</text>
  </g>
  <text x="330" y="170" textAnchor="middle" style={{ fill: "var(--ngc-slate)", fontFamily: "var(--sans)", fontSize: 12 }}>On every later request, middleware reads the session and knows who is visiting.</text>
</svg>
<figcaption>The password is checked once. After that, the session remembers who you are.</figcaption>
</figure>

1. **Identity**: the visitor proves who they are, usually with an email and a password.
2. **Session**: the server remembers them, with the user stored in `Astro.session`.
3. **Cookie**: the browser keeps only the session ID and sends it with every request.

### Choosing Between Sessions and Auth Libraries

- Build it yourself (this lesson) to **learn** how login works, or for a very small private site.
- Use an **auth library** for real products. It also handles password resets, email checks, "Log in with Google" and many attacks you might forget.

**Simple lesson:** prove identity once, remember it in the session, and read it on every request.

## Session-Based Login

### Hashing and Storing Credentials Safely

- **Never** store passwords as plain text. If the database leaks, every account leaks.
- **Hash** = a one-way scramble of the password. You can check a password against it, but you cannot turn it back.
- **Salt** = a random value added before hashing, so two people with the same password get different hashes.
- Use a slow hash made for passwords: **Argon2**, **bcrypt**, **scrypt** or **PBKDF2**. Slow hashes make guessing millions of passwords very expensive.

Press **▶ Run**. The same password with different salts gives different hashes. Only the right password matches.

<p><code>{a.slice(0, 40)}…</code></p>
<p><code>{b.slice(0, 40)}…</code></p>
<p>Same password, different salt: different hashes.</p>
<p>Right password matches: <strong>{String(rightOk)}</strong></p>
<p>Wrong password matches: <strong>{String(wrongOk)}</strong></p>
`,
  }}
/>

### Creating Login and Logout Actions

- **Login action**:
  1. check the email and password against the stored hash,
  2. on failure, `throw new ActionError({ code: "UNAUTHORIZED" })` with a vague message such as "Wrong email or password",
  3. on success, `await context.session.regenerate()`, then `context.session.set("user", user)`.
- **Logout action**: `context.session.destroy()`.
- Store only what pages need in the session: an ID, a name, a role. Never the password or its hash.

Press **▶ Run**. Try the wrong password, then the right one. Read the session lines in the **Terminal**.

<button id="right">Log in with the right password</button>
<p id="out"></p>

<script>
    const out = document.getElementById("out")!;

    async function tryLogin(password: string) {
      const { data, error } = await actions.login({ email: "asha@example.com", password });
      out.textContent = error ? error.code + ": " + error.message : data;
    }

    document.getElementById("wrong")!.addEventListener("click", () => tryLogin("guess"));
    document.getElementById("right")!.addEventListener("click", () => tryLogin("astro123"));
</script>
`,
  }}
/>

Say "Wrong email or password", not "No account with that email". A precise message tells attackers which emails have accounts.

**Simple lesson:** hash passwords with a slow, salted hash. Regenerate the session on login and destroy it on logout.

## Protecting Routes

### Checking the User in Middleware

- Read the user once, in middleware: `context.locals.user = await context.session?.get("user")`.
- Then every page, endpoint and action can use `locals.user`.

### Redirecting Unauthenticated Visitors

- If a protected page has no user, `return context.redirect("/login")`.
- Add where they wanted to go, so you can send them back after login: `/login?next=/dashboard`.

### Making the Logged-In User Available to Pages

- Pages read `Astro.locals.user`. They do not need to touch the session.

### [TS] Typing the Logged-In User

```ts
declare namespace App {
  interface Locals {
    user?: { id: string; name: string; role: "admin" | "writer" };
  }
  interface SessionData {
    user: { id: string; name: string; role: "admin" | "writer" };
  }
}
```

### Protecting Endpoints

- An endpoint is just as public as a page. Check `context.locals.user` there too.
- Answer `401` when nobody is logged in.

### Why Matching context.url.pathname Strings Is Unsafe

- A rule like `pathname === "/admin"` misses `/admin/`, `/Admin` and `/admin/settings`.
- Encoded addresses, such as `/%61dmin`, can also slip past simple string checks.
- Safer:
  - protect a whole **prefix**, normalised first,
  - and **also** check the user inside each private page, endpoint and action.
- Middleware is the first gate, not the only one.

Press **▶ Run**. Try `/api/me.json` before and after logging in. The page under `/dashboard/settings` is protected by the prefix rule.

<form method="post"><button>Log in as Asha (demo)</button></form>
`,
    "src/pages/dashboard/settings.astro": `<h1>Settings for {Astro.locals.user?.name}</h1>
<a href="/api/me.json">My data</a>
`,
  }}
/>

`/login` itself must stay public, or nobody could ever log in. Check that your protected list does not cover it.

**Simple lesson:** read the user in middleware, guard whole prefixes, and check again inside private endpoints.

## Authorization

### Checking Roles and Permissions

- **Role** = a label for what a user may do: `"admin"`, `"writer"`, `"reader"`.
- Check it before the action: `if (user.role !== "admin") throw new ActionError({ code: "FORBIDDEN" })`.

### Checking Resource Ownership

- **Ownership** = "this item belongs to this user".
- Ravi may edit drafts, but only **his** drafts: compare `draft.authorId === user.id`.
- Roles say what kind of thing you may do. Ownership says on which items.

### 401 Unauthorized vs 403 Forbidden

- **401 Unauthorized** = "I do not know who you are". Log in, then try again.
- **403 Forbidden** = "I know who you are, and you may not do this". Logging in again will not help.

Press **▶ Run**. Log in as Ravi, then try to edit both drafts and open the admin page.

<form method="post">
    <button name="as" value="ravi">Log in as Ravi (writer)</button>
    <button name="as" value="asha">Log in as Asha (admin)</button>
</form>
<form method="post">
    {drafts.map((d) => <p>{d.title} <button name="edit" value={d.id}>Edit</button></p>)}
</form>
<p class="msg">{message}</p>
<a href="/admin">Admin page</a>
`,
    "src/pages/admin.astro": `---
const user = Astro.locals.user;
if (!user) return new Response("401: log in first", { status: 401 });
if (user.role !== "admin") return new Response("403: admins only", { status: 403 });
---

<h1>Admin area</h1>
<p>Welcome, {user.name}.</p>
`,
  }}
/>

**Simple lesson:** roles decide **what** you may do, ownership decides **which** items. 401 means "log in", 403 means "not allowed".

## Auth Libraries and Services

Real products usually use a library or a service:

- **Better Auth** = an open-source TypeScript library. Email and password, social logins, two-factor login. Has an Astro guide.
- **Clerk** = a hosted service with ready-made sign-in pages and an official Astro SDK.
- **Lucia** = now a learning resource with guides for building session auth yourself, like this lesson.
- **Scalekit** = a hosted service focused on business logins, such as company single sign-on.

Security code is easy to get almost right. For anything with real users, prefer a well-tested library or service, and keep your own checks in middleware and handlers.

**Simple lesson:** learn the pieces yourself, then use a tested library for real users.

## TL;DR

1. **Authentication** = who you are. **Authorization** = what you may do.
2. **Login** = check identity → store user in the session → cookie holds only the ID.
3. **Passwords** = slow, salted hashes (Argon2, bcrypt, scrypt, PBKDF2). Never plain text.
4. **Login action** = verify → `regenerate()` → `set("user", ...)`. **Logout** = `destroy()`.
5. **Middleware** = `locals.user = await session.get("user")`, then redirect if a protected prefix has no user.
6. **Path checks** = normalise and protect prefixes, and check again in endpoints and actions.
7. **Roles and ownership** = check both before changing data.
8. **401** = not logged in. **403** = logged in, not allowed.
9. **Libraries** = Better Auth, Clerk, Lucia guides, Scalekit.

## Common mistakes

<div className="tscroll">

| Mistake | Wrong idea | Correct idea | What goes wrong |
|---|---|---|---|
| Plain-text passwords | The database is private | Store slow, salted hashes | A leak exposes every password |
| No `regenerate()` on login | The old ID is fine | Regenerate on login | Session fixation |
| Only `pathname === "/admin"` | One exact check covers it | Normalise, protect prefixes, check again inside | `/admin/` or `/Admin` slips through |
| Hiding links instead of checking | No link, no access | Check on the server | Anyone can type the address |
| 403 for "not logged in" | 401 and 403 are the same | 401 = log in, 403 = not allowed | Confusing errors for users and apps |
| Telling which emails exist | Clear errors help | "Wrong email or password" | Attackers learn valid accounts |

</div>

## Check Yourself

## Exercises

## What to Remember

- Authentication proves who someone is. Authorization decides what they may do.
- Store slow, salted password hashes. Regenerate the session on login, destroy it on logout.
- Read the user in middleware into `locals`, protect whole prefixes, and check again in endpoints and actions.
- Check roles and ownership. Answer 401 for "log in" and 403 for "not allowed".
- Use a tested auth library for real users.

**Checkpoint:** Asha's site now has endpoints, middleware, cookies, sessions, actions and a working login. That is a full-stack site.

Next: **Fresh Data at Scale**, where server islands and live collections keep busy pages fast.
