Type something to search...
How to protect routes in Astro with middleware?

How to protect routes in Astro with middleware?

To protect routes in Astro with middleware, create src/middleware.ts and export an onRequest handler that does two jobs: it loads the current user from the session or auth library into context.locals, and it checks whether the requested route needs a signed-in user. If it does and there's no user, the middleware returns a redirect to the login page, or a 401 response for API routes, instead of calling next(). Match protected routes with context.routePattern, the route Astro actually resolved, rather than comparing raw URL strings, and remember that the protected pages must be rendered on demand.

Checking for a user at the top of every page works, but it's easy to forget one, and a forgotten check is a data leak. Middleware puts the rule in one place that runs before every on-demand request. In this article you'll learn how middleware fits into protection, how to split user loading and access rules with sequence(), how to match routes safely, how to redirect with a return URL without creating an open redirect, how to handle roles and API routes, how the Astro 7 fetch entrypoint offers a Hono-based alternative, and which mistakes to avoid.

How Middleware Protects Routes

Middleware runs before Astro renders a page or endpoint. It receives the request context and a next() function. Calling next() continues to the page; returning a Response instead stops the request there. That's all route protection is: decide whether to call next().

Two conditions must hold for this to work:

  • The route is rendered on demand: middleware runs at build time for prerendered pages, when there's no visitor and no cookie. A prerendered "protected" page is just a public HTML file. Add export const prerender = false to protected pages or use output: 'server'. See mixing prerendered and on-demand pages.
  • You have a way to identify the user: a session, an auth library or a signed cookie. The examples below use Astro sessions to store a user ID, but the same structure works with Better Auth, Clerk or anything else.

If middleware itself is new to you, read what Astro middleware is and how to use it first. All examples were built and run on Astro 7.4.2 with @astrojs/node.

Step 1: Type the User in Locals

Declare what the middleware will put in locals, so pages and endpoints get autocompletion:

// src/env.d.ts
declare namespace App {
  interface Locals {
    user: { id: string; name: string; role: 'member' | 'admin' } | null;
  }
  interface SessionData {
    userId: string;
  }
}

For the demo, users live in a small lookup module. In a real app this would query your database:

// src/lib/users.ts
type User = NonNullable<App.Locals['user']>;

const users: Record<string, User> = {
  u1: { id: 'u1', name: 'Ada', role: 'admin' },
  u2: { id: 'u2', name: 'Grace', role: 'member' },
};

export async function findUser(id: string | undefined): Promise<User | null> {
  return id ? (users[id] ?? null) : null;
}

Step 2: Write the Middleware

Splitting the work into two middleware functions keeps each one simple. sequence() runs them in order:

// src/middleware.ts
import { defineMiddleware, sequence } from 'astro:middleware';
import { findUser } from './lib/users';

const loadUser = defineMiddleware(async (context, next) => {
  context.locals.user = null;
  if (context.isPrerendered) return next();

  const userId = await context.session?.get('userId');
  context.locals.user = await findUser(userId);
  return next();
});

const rules = [
  { prefix: '/admin', role: 'admin' },
  { prefix: '/api/admin', role: 'admin' },
  { prefix: '/account', role: 'member' },
] as const;

function matches(pattern: string, prefix: string) {
  return pattern === prefix || pattern.startsWith(prefix + '/');
}

const guard = defineMiddleware((context, next) => {
  const rule = rules.find((r) => matches(context.routePattern, r.prefix));
  if (!rule) return next();

  const { user } = context.locals;
  const isApi = context.routePattern.startsWith('/api/');

  if (!user) {
    if (isApi) return new Response('Unauthorized', { status: 401 });
    const loginUrl = new URL('/login', context.url);
    loginUrl.searchParams.set('next', context.url.pathname);
    return context.redirect(loginUrl.pathname + loginUrl.search);
  }

  if (rule.role === 'admin' && user.role !== 'admin') {
    if (isApi) return new Response('Forbidden', { status: 403 });
    return context.rewrite('/403');
  }

  return next();
});

export const onRequest = sequence(loadUser, guard);

Here's what each part does:

  • loadUser: always sets locals.user, to null if nobody is signed in. Skipping the session lookup when context.isPrerendered is true avoids touching cookies while static pages are built.
  • rules: a single list of protected areas and the role each needs. Adding a new protected section means adding one line.
  • matches(): matches a prefix exactly or as a path segment, so /admin protects /admin/users but not a hypothetical /administrators-guide.
  • guard: redirects anonymous visitors to the login page, returns 401 or 403 for API routes, and rewrites to a 403 page when a signed-in user lacks the right role.

Why routePattern and Not the Pathname

The Astro docs warn that authorising requests by comparing context.url.pathname to strings can be bypassed. Base paths, URL encoding and duplicate slashes can all make the pathname a request arrives with differ from the route Astro finally matches. A check that misses that difference lets the request through to a page it should have blocked.

context.routePattern avoids the problem because it's the route Astro resolved, written the way your files are named. For src/pages/admin/[section].astro it's /admin/[section], whatever the URL looked like, and it's not affected by your base setting. If the request reaches a protected page, the pattern says so.

Testing the built site against some tricky URLs shows the guard catching every variant that resolves to an admin page:

/admin           -> 302 /login?next=%2Fadmin
/admin/users     -> 302 /login?next=%2Fadmin%2Fusers
/%61dmin         -> 302 /login?next=%2Fadmin
//admin          -> 302 /login?next=%2Fadmin
/admin/          -> 302 /login?next=%2Fadmin%2F
/ADMIN           -> 404
/api/admin/stats -> 401

/%61dmin is /admin with the a percent-encoded, and it was still recognised as the admin route. /ADMIN returned a 404 because Astro's routes are case-sensitive, so no protected page was ever reached.

Step 3: Add the Login and 403 Pages

The login page reads the next parameter, signs the user in and sends them back. This demo picks a user from a dropdown; a real app verifies a password or delegates to a library, as shown in adding user authentication to an Astro site:

---
// src/pages/login.astro
export const prerender = false;

function safeNext(value: string | null) {
  return value && value.startsWith('/') && !value.startsWith('//') ? value : '/account';
}

const next = safeNext(Astro.url.searchParams.get('next'));

if (Astro.request.method === 'POST') {
  const form = await Astro.request.formData();
  const id = String(form.get('user'));
  // Demo only: a real app verifies a password or uses an auth library.
  await Astro.session?.regenerate();
  Astro.session?.set('userId', id);
  return Astro.redirect(next, 303);
}
---
<form method="POST">
  <select name="user">
    <option value="u1">Ada (admin)</option>
    <option value="u2">Grace (member)</option>
  </select>
  <button>Sign in</button>
</form>

Avoiding an Open Redirect

The safeNext() function matters. Without it, an attacker could send someone a link like /login?next=https://evil.example or /login?next=//evil.example, and after signing in on your real site, the victim would be sent to the attacker's page. Accepting only paths that start with a single / keeps redirects on your own site. In the test, a login with next=//evil.example landed on /account instead.

The 403 Page Must Be On Demand

The guard uses context.rewrite('/403') to show a "no access" page while keeping the URL. The first version of this page was prerendered, and the rewrite failed with a 500:

[ERROR] Error: Unexpectedly unable to find a component instance for route /403

Rewriting from an on-demand request to a prerendered page doesn't work in a server build, because the prerendered page isn't part of the server bundle. Marking the page as on demand fixes it, and lets it set a proper status code:

---
// src/pages/403.astro
export const prerender = false;
Astro.response.status = 403;
---
<h1>You don't have access to this page</h1>

Testing Roles

With a member signed in (Grace), the account page works, while admin pages and the admin API are refused:

member /account          -> 200 <h1>Account for Grace</h1>
member /admin            -> 403 <h1>You don't have access to this page</h1>
member /admin/users      -> 403 <h1>You don't have access to this page</h1>
member /api/admin/stats  -> 403 Forbidden

With an admin (Ada), everything is allowed:

admin /admin            -> 200 <h1>Admin for Ada</h1>
admin /admin/users      -> 200 <h1>Admin section users</h1>
admin /api/admin/stats  -> 200 {"users":2}

Note that a signed-in member lands on the 403 page rather than the login page. Sending them to log in again would be confusing, since they already are logged in; they simply lack permission.

Protecting API Routes and Actions

API routes need different responses from pages. A fetch() call that receives a redirect to an HTML login page is hard to handle, so return a status code instead: 401 Unauthorized when nobody is signed in and 403 Forbidden when the user lacks permission. The isApi check in the guard does exactly that.

Astro Actions are served under /_actions/<name>, so they don't match your page patterns. Protect them inside each handler by reading context.locals.user and throwing an ActionError with the UNAUTHORIZED or FORBIDDEN code. You can add a coarse filter in middleware with getActionContext(), but the handler check is the one that counts.

Defence in Depth

Middleware is the main gate, but don't let it be the only one:

  • Check in the page too when it's cheap: a one-line if (!Astro.locals.user) return Astro.redirect('/login') in a sensitive page costs nothing and survives a mistake in your rules list.
  • Authorise data, not just routes: an account page that loads an order by ID must also check that the order belongs to locals.user. Route protection doesn't stop one user requesting another user's data.
  • Don't rely on hiding links: removing the admin link from the menu isn't protection. The route must refuse the request.
  • Keep server islands in mind: server islands are fetched from /_server-islands/... URLs. Inside an island, Astro.url points at the island endpoint rather than the page, so check the user inside the island component when it shows private data.

Alternative: Hono in the Fetch Entrypoint

Astro 7 turned on advanced routing by default. If src/fetch.ts exists, Astro uses it as the server's request entrypoint, and the astro/hono module exports Astro's pipeline as Hono middleware. That lets you protect routes with Hono's router, which matches routes for you:

// src/fetch.ts
import { Hono, type Context, type Next } from 'hono';
import { getCookie } from 'hono/cookie';
import { astro } from 'astro/hono';

const app = new Hono();

async function requireSession(c: Context, next: Next) {
  if (!getCookie(c, 'astro-session')) {
    return c.redirect('/login');
  }
  return next();
}

app.use('/admin', requireSession);
app.use('/admin/*', requireSession);
app.use(astro());

export default app;

After npm install hono, a build with this file redirected /admin, /admin/users and /%61dmin to /login and served /account normally. This example only checks that a session cookie exists, which is enough to show the wiring but isn't real authorisation. The Astro authentication guide shows the same structure with Better Auth's auth.api.getSession() in place of the cookie check. If you already have a src/fetch.ts that does something else, rename it or set fetchFile in your config, because the filename is now reserved.

For most sites, src/middleware.ts remains the simpler choice. The fetch entrypoint is worth it when you want Hono's routing or existing Hono middleware.

Common Mistakes

  1. Protecting prerendered pages: the HTML is generated at build time and served to everyone. Make protected pages on demand.
  2. Forgetting to return: context.redirect() creates a response, but only returning it stops the request.
  3. String-matching URLs: compare context.routePattern or use a router, not url.pathname.startsWith().
  4. Redirecting API calls to HTML: return 401 or 403 for endpoints.
  5. Trusting next parameters: validate return URLs to avoid open redirects.
  6. Skipping action checks: actions live under /_actions/, so check permissions in each handler.

FAQ: Protecting Routes in Astro

Yes, but only once at build time, when there is no visitor or cookie. It can't protect prerendered pages, so any page that needs a signed-in user must be rendered on demand.

Return context.redirect('/login') instead of calling next(). Add the original path as a query parameter if you want to send the user back after signing in, and validate it before redirecting.

It's the route Astro matched for the request, written like your file names, such as /admin/[section]. Matching on it is safer than comparing the raw URL pathname, which can vary with encoding, slashes and base paths.

Return an error status. Use 401 when no user is signed in and 403 when the user lacks permission, so client code can handle the response without parsing an HTML login page.

Check context.locals.user inside each action handler and throw an ActionError with the UNAUTHORIZED or FORBIDDEN code. Middleware can add a coarse filter with getActionContext, but the handler check is the reliable one.

Yes. In Astro 7, src/fetch.ts is the request entrypoint and astro/hono exports Astro's pipeline as Hono middleware, so you can register auth middleware on Hono routes before app.use(astro()).


Conclusion

Protecting routes in Astro comes down to one rule in one place: middleware loads the user into locals, checks the matched route against a list of protected areas, and returns a redirect, a 401, a 403 or a rewrite instead of calling next() when access is denied. Matching on context.routePattern rather than the raw pathname, validating return URLs and keeping protected pages on demand closes the most common gaps.

Back that up with checks inside actions, sensitive pages and data queries, so one mistake in your rules doesn't expose private data. If you prefer a router-based approach, Astro 7's src/fetch.ts with astro/hono lets you use Hono's middleware instead. Either way, a small amount of carefully placed code keeps your private pages private.

Tags :
Share :

Related Posts

What are Astro Actions and how do they work?

What are Astro Actions and how do they work?

Astro Actions are type-safe backend functions that you define once on the server and then call from the browser as if they were ordinary async fu

Dive Deeper
What are Astro adapters and which one should you choose?

What are Astro adapters and which one should you choose?

An Astro adapter is an integration that builds the server part of your project for a specific runtime, such as Node.js, Vercel Functions, Netlify

Dive Deeper
How to build API endpoints in Astro?

How to build API endpoints in Astro?

To build an API endpoint in Astro, create a .js or .ts file in src/pages/ and export a function named after an HTTP method, such as GET or

Dive Deeper