如何为 Astro 站点添加身份验证
使用 adapter、middleware、Astro.locals、sessions、rewrite 和 Actions 配置 Astro 认证,涵盖登录、退出与路由保护。
Astro 中的身份验证既取决于你选择哪个库,也同样取决于页面以何种方式渲染。默认情况下,Astro 会在构建时预渲染项目中的页面,你需要为单独的路由选择退出这一行为。预渲染页面是在任何访客出现之前就生成好的,因此不存在请求、不存在 cookie 头,也不存在可读取的 session。
正因如此,第一次尝试常常悄无声息地失败。你安装了一个 auth 库,把它的中间件粘贴进 src/middleware.ts,加载 /account,结果 Astro.locals 是个空对象。没有报错,没有日志,也没有可供搜索的堆栈跟踪。
本文按顺序、逐个文件地走完整条链路:adapter、每个路由的 prerender 导出、负责解析用户的中间件、读取用户的页面、使用 rewrite() 进行路由保护、以 Astro Actions 实现登录与登出,以及客户端 island 无法再看到这一切的边界。示例代码面向 Astro 7.x。Astro 6 将 Node 的最低版本提升到了 22,并放弃了对 Node 18 和 20 的支持,因此请运行 Node 22.12.0 或更高版本。
核心要点
- Astro 默认对整个项目进行预渲染,因此任何需要读取 session 的页面都必须通过
export const prerender = false选择退出,而按需渲染需要一个 adapter。 - 如果你的中间件看起来毫无作用,那么它本应保护的那个路由几乎肯定仍处于预渲染状态。
Astro.locals的生命周期恰好只有一次路由渲染;需要延续到下一个请求的数据应放在Astro.session中,而这需要一个 session driver。- Astro Actions 是可被公开访问的 HTTP 端点,因此每个 handler 都需要自己的
context.locals检查;保护了渲染表单的页面,并不等于保护了其背后的 action。 Astro.locals和Astro.session仅存在于服务端,因此带有client:*指令的组件只能看到页面通过 props 传给它的内容。
为什么”默认静态”会破坏 Astro 的身份验证?
除非某个路由另有要求,否则 Astro 项目会构建为静态 HTML,而静态 HTML 是在构建时一次性生成、面向所有访客的。对整个项目进行预渲染是有文档记载的默认行为,正如按需渲染指南所述。session 存在于请求头中,而当这些 HTML 被写入磁盘时,该请求头尚不存在,因此 cookie 读取、Astro.request,以及中间件放到 locals 上的任何内容都无从附着。
实际后果是:中间件看起来从未运行,这是渲染模式的症状,而不是 auth 库的 bug。
如何启用按需渲染?
按需渲染需要两样东西:一个 adapter,用于为目标运行时生成服务器;以及按路由的选择退出。Astro 的官方 adapter 有 @astrojs/cloudflare、@astrojs/netlify、@astrojs/node 和 @astrojs/vercel,此外还有社区 adapter,adapter 选项在配置参考中有说明。用 npx astro add node 安装其中一个,并查阅该 adapter 自己的文档页面,因为各自的配置项有所不同。
然后选择一种站点形态。output 选项只接受两个值:'static' 和 'server'。
| 站点形态 | astro.config.mjs | 页面 frontmatter |
|---|---|---|
| 内容型站点,仅少数页面需要登录 | 安装 adapter,保留默认的 output: 'static' | 在每个读取 session 的路由上写 export const prerender = false |
| 以登录态为主的应用 | 安装 adapter,设置 output: 'server' | 在营销页和内容路由上写 export const prerender = true |
无论选择哪种,规则都一样:读取 session 的路由必须按需渲染,而 Node adapter 文档涵盖了此处示例所涉及的运行时细节。
中间件:src/middleware.ts
中间件在每个请求中解析一次用户,并把结果交给下游的一切。将文件放在 src/middleware.js|ts,或者如果你偏好目录结构,放在 src/middleware/index.js|ts,并提供一个名为 onRequest 的具名导出。默认导出不会被识别,中间件指南对这条规则有明确说明。
// src/middleware.ts
import { defineMiddleware } from 'astro:middleware';
export const onRequest = defineMiddleware(async (context, next) => {
const stored = (await context.session?.get('user')) ?? null;
context.locals.user = stored as User | null;
return next();
});
这里读取的是 Astro 内置的 session,而不是手工解析 cookie。正如 Astro 的 sessions 指南所述,同一个 session 会以两个名字出现:页面和组件通过 Astro.session 访问它,而中间件、API 端点和 action handler 则从 context.session 获取。存储并非自动配置。有三个 adapter 会为你选定默认 driver:Node、Cloudflare 和 Netlify;使用其他 adapter 时,你需要按照 session driver 参考自行指定。在部署到 edge 运行时之前,有一个约束值得了解:session 在 edge 中间件中无法工作。
在 src/env.d.ts 中通过扩展 App 命名空间来为 locals 添加类型:
// src/env.d.ts
type User = {
id: string;
email: string;
};
declare namespace App {
interface Locals {
user: User | null;
}
}
使用 Astro.locals 读取用户
只要页面是按需渲染的,它就能读取中间件所赋的值。Astro.locals 的生命周期恰好只有一次路由渲染,因此它适合把用户对象从中间件带到页面,却不适合存放任何需要延续到下一个请求的内容。
---
// src/pages/account.astro
/* On-demand rendering */ export const prerender = false;
const user = Astro.locals.user;
if (!user) return Astro.redirect('/login');
---
<h1>Signed in as {user.email}</h1>
删掉第一行,这个页面就会回到预渲染集合中:它会被构建成静态 HTML,Astro.locals.user 永远不会被填充,每位访客拿到的都是同一个文件。这一行,就是身份验证正常工作与”谁也过不去的登录界面”之间的分界线。
如何用 context.rewrite() 保护路由?
保护路由的做法是:把校验关口集中到中间件中,并就地渲染登录页,而不是重定向过去。context.rewrite('/login') 会在访客请求的那个 URL 上展示不同的内容,从而让受保护的路径继续留在地址栏里。
// src/middleware.ts
import { defineMiddleware } from 'astro:middleware';
const protectedPaths = ['/account', '/dashboard'];
export const onRequest = defineMiddleware(async (context, next) => {
const stored = (await context.session?.get('user')) ?? null;
context.locals.user = stored as User | null;
const needsAuth = protectedPaths.some((path) =>
context.url.pathname.startsWith(path),
);
if (needsAuth && !context.locals.user) {
return context.rewrite('/login');
}
return next();
});
由于 rewrite 会重新开始渲染,你的中间件在这个过程中会再运行一次,因此务必把 /login 排除在 protectedPaths 之外,否则第二次执行会在同一处检查上失败并陷入循环。这类身份验证故障很少会抛出异常:对登录流程做 session replay,会看到用户在登录页和受保护路由之间反复跳转——从外部看,作用域配置错误的 session cookie,或者放错分支的校验关口,正是这副样子。
在这里应避免另一种 rewrite 形式。当你给 next() 传入一个 Request 时,Astro 会基于旧请求构造一个替代请求,此后(或此前)任何读取请求体的尝试都会在运行时抛出异常。当 Action 由 HTML 表单驱动时,这一点的杀伤力最大,这也是文档引导你改用 context.rewrite() 或 Astro.rewrite() 的原因。
用 Astro Actions 实现登录与登出
Astro Actions 于 astro@4.15 引入,是处理登录和登出的内置方式,可以取代手写的 API 路由。在 src/actions/index.ts 导出的 server 对象中定义它们,设置 accept: 'form',然后用纯 HTML 向其提交。
// src/actions/index.ts
import { ActionError, defineAction } from 'astro:actions';
import { z } from 'astro/zod';
// Replace with your own credential lookup.
async function verifyCredentials(email: string, password: string): Promise<User | null> {
return null;
}
export const server = {
login: defineAction({
accept: 'form',
input: z.object({
email: z.email({ error: 'Enter a valid email address.' }),
password: z.string(),
}),
handler: async ({ email, password }, context) => {
const user = await verifyCredentials(email, password);
if (!user) throw new ActionError({ code: 'UNAUTHORIZED' });
await context.session?.regenerate();
await context.session?.set('user', user);
return { ok: true };
},
}),
logout: defineAction({
accept: 'form',
handler: async (_input, context) => {
await context.session?.destroy();
return { ok: true };
},
}),
deleteAccount: defineAction({
accept: 'form',
handler: async (_input, context) => {
if (!context.locals.user) throw new ActionError({ code: 'UNAUTHORIZED' });
return { ok: true };
},
}),
};
z 来自 astro/zod,它重新导出了 Zod v4,因此像 z.email() 这样的顶层校验器以及用于自定义消息的 error 键是当前的写法。登录时重新生成 session ID 可以防范 session fixation 攻击,而 destroy() 会清除 cookie 并丢弃服务端保存的数据。
注意 deleteAccount。Actions 是拥有各自 URL 的公开可访问端点,因此任何人都可以直接调用,而无需加载渲染其表单的那个页面。每个接触用户数据的 handler 都要自行检查 context.locals。
页面这一侧则是一个表单加上对结果的读取:
---
// src/pages/login.astro
/* On-demand rendering */ export const prerender = false;
import { actions } from 'astro:actions';
const result = Astro.getActionResult(actions.login);
if (result && !result.error) return Astro.redirect('/account');
---
{result?.error && <p class="error">Those details did not match.</p>}
<form method="POST" action={actions.login}>
<input type="email" name="email" required />
<input type="password" name="password" required />
<button>Log in</button>
</form>
登出的形式相同:<form method="POST" action={actions.logout}>,不需要任何客户端 JavaScript。
Island 陷阱:Island 永远看不到 locals
Astro.locals 和 Astro.session 仅存在于服务端。中间件、.astro 页面与布局、API 路由和 action handler 都运行在服务端并共享它们。带有 client:* 指令的组件会在浏览器中 hydrate,位于这条链路之外,只能看到页面通过 props 传给它的内容。
---
// Renders logged-out UI forever. The island cannot reach locals.
import UserMenu from '../components/UserMenu.jsx';
---
<UserMenu client:load />
---
// Correct: the page reads locals on the server and passes the value down.
import UserMenu from '../components/UserMenu.jsx';
const user = Astro.locals.user;
---
<UserMenu client:load user={user} />
出错的那个版本不会抛出任何异常。页面渲染的是登录态内容,而它旁边的 island 却渲染出一个登录按钮——这种不一致只能在视觉上被发现。
本示例使用的是 Astro 内置的 sessions,而换成第三方库时,同样的顺序依然成立:Astro 的身份验证指南推荐了 Better Auth 和 Clerk 等 auth 库来实现邮箱登录和 OAuth,而 Better Auth 与框架无关的设计也适用于 Astro,正如我们的 BetterAuth 概览所解释的。无论你选择哪一个,它依然需要一个 adapter、一个非预渲染的路由,以及一个会写入 context.locals 的中间件。
小结
Astro 中的身份验证是一条链条,而最脆弱的一环就在最前端:没有 adapter、没有 prerender = false,就没有请求,下游的一切都会悄无声息地什么也不做。先把渲染模式弄对,然后是中间件,再然后是为每个 action handler 加上检查。打开按需渲染指南,对照你的 astro.config.mjs,安装一个 adapter,并给第一个需要用户信息的页面加上 export const prerender = false。
常见问题
Astro Actions 能在预渲染页面上工作吗?
不能。页面必须按需渲染,才能通过表单的 action 调用 action,所以要给包含该表单的页面加上 'export const prerender = false',并安装一个 adapter 以便有服务器来运行 handler。Action 的请求体还有 1 MB(1048576 字节)的默认上限;如果某个 handler 需要接收更大的内容(例如上传文件),请调高 security.actionBodySizeLimit。
预渲染的静态页面能展示登录态或未登录态的 UI 吗?
在服务端不行。预渲染页面在构建时就被写入磁盘,每位访客收到的都是完全相同的文件,因此没有可供判断分支的 cookie 头。有两种可行的解决办法:用 'export const prerender = false' 让该路由退出预渲染;或者保持页面静态,在浏览器中从一个按需渲染的端点获取用户信息,再把结果传入客户端组件。
Astro 会自动为登录表单提供 CSRF 防护吗?
部分会。在按需渲染的页面上,Astro 会将浏览器发送的 origin 头与请求所指向的 URL 进行比对,两者不一致时返回 403。这一行为自 Astro 5 起通过 security.checkOrigin 选项默认开启,且仅覆盖跨站表单提交。你仍然需要在登录时重新生成 session ID,仍然需要对每个 action handler 和 API 路由分别做授权检查。将 security.checkOrigin 设为 false 会禁用该检查。
应该用 context.rewrite 还是 Astro.redirect 把未登录用户送到登录页?
如果你希望在访客请求的那个 URL 上直接提供登录内容,就在中间件中使用 context.rewrite,因为它能让受保护的路径保留在地址栏中,并避免浏览器的第二次往返。Astro.redirect 会返回一个重定向响应,浏览器随后导航到 /login。rewrite 会触发一次全新的渲染,你的中间件会再次运行,所以要把 /login 排除在受保护路径之外,否则同一处检查会持续失败。
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k