Astro サイトに認証を追加する方法
Astroの認証をadapter、middleware、Astro.locals、sessions、rewrite、Actionsで設定。login、logout、ルート保護まで解説。
Astro における認証は、どのライブラリを選ぶかと同じくらい、ページがどのようにレンダリングされるかに依存します。デフォルトでは、Astro はプロジェクト内のページをビルド時にプリレンダリングし、個々のルートをそこから除外する形になります。プリレンダリングされたページは訪問者が存在する前に生成されるため、リクエストも Cookie ヘッダーも、読み取るべきセッションも存在しません。
最初の試みが静かに失敗するのは、たいていこれが理由です。認証ライブラリをインストールし、そのミドルウェアを src/middleware.ts に貼り付け、/account を読み込むと、Astro.locals は空のオブジェクトになっています。エラーもログも、検索できるスタックトレースもありません。
この記事では、その一連の流れをファイル単位で順に追っていきます。アダプター、ルートごとの prerender エクスポート、ユーザーを解決するミドルウェア、それを読み取るページ、rewrite() によるルート保護、Astro Actions としてのログインとログアウト、そしてクライアントサイドのアイランドがそれらを一切参照できなくなる境界までです。コードは Astro 7.x を対象としています。Astro 6 で Node の下限が 22 に引き上げられ、Node 18 と 20 のサポートは廃止されたため、Node 22.12.0 以降を使用してください。
重要なポイント
- Astro はデフォルトでプロジェクト全体をプリレンダリングするため、セッションを読み取るページは
export const prerender = falseでオプトアウトする必要があり、オンデマンドレンダリングにはアダプターが必要です。 - ミドルウェアが何もしていないように見える場合、保護対象のルートがまだプリレンダリングされている可能性がほぼ確実です。
Astro.localsは 1 回のルートレンダリングの間しか存続しません。次のリクエストまで残す必要があるデータはAstro.sessionに置くべきであり、これにはセッションドライバーが必要です。- Astro Actions は公開された HTTP エンドポイントであるため、すべてのハンドラーに個別の
context.localsチェックが必要です。フォームをレンダリングするページを保護しても、その背後にあるアクションは保護されません。 Astro.localsとAstro.sessionはサーバーサイド専用であるため、client:*ディレクティブを持つコンポーネントは、ページから props として渡されたものしか参照できません。
なぜ「デフォルトで静的」が Astro の認証を壊すのか?
Astro プロジェクトは、ルートが別の指定をしない限り静的 HTML にビルドされ、静的 HTML はビルド時に一度だけ、すべての訪問者に向けて生成されます。プロジェクト全体をプリレンダリングすることがドキュメント上のデフォルトであることは、オンデマンドレンダリングのガイドに記載されています。セッションはリクエストヘッダーの中に存在しますが、その HTML がディスクに書き出される時点ではまだ存在しません。したがって Cookie の読み取り、Astro.request、ミドルウェアが locals に載せるものには、結び付く先がありません。
実務上の帰結はこうです。ミドルウェアがまったく実行されていないように見えるのは、レンダリングモードの症状であって、認証ライブラリのバグではありません。
オンデマンドレンダリングを有効にするには?
オンデマンドレンダリングには 2 つのものが必要です。ターゲットランタイム向けのサーバーを生成するアダプターと、ルートごとのオプトアウトです。Astro のファーストパーティアダプターは @astrojs/cloudflare、@astrojs/netlify、@astrojs/node、@astrojs/vercel で、これらに加えてコミュニティ製のアダプターもあります。adapter オプションは設定リファレンスに記載されています。npx astro add node でインストールし、設定オプションはアダプターごとに異なるため、該当アダプターのページも確認してください。
次に構成を選びます。output オプションが取る値は 'static' と 'server' の 2 つだけです。
| サイトの構成 | astro.config.mjs | ページの frontmatter |
|---|---|---|
| ログインが必要なページが少数あるコンテンツサイト | アダプターをインストールし、デフォルトの output: 'static' を維持 | セッションを読み取るすべてのルートに export const prerender = false |
| 大部分がログイン前提のアプリ | アダプターをインストールし、output: 'server' を設定 | マーケティングページやコンテンツルートに export const prerender = true |
どちらを選んでも、ルールは同じです。セッションを読み取るルートはオンデマンドレンダリングでなければなりません。ここでの例におけるランタイム固有の詳細は、Node アダプターのドキュメントでカバーされています。
ミドルウェア: 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();
});
これは Cookie を手作業でパースするのではなく、Astro 組み込みのセッションを読み取っています。Astro のセッションガイドが説明しているとおり、同じセッションは 2 つの名前で登場します。ページとコンポーネントは Astro.session 経由でアクセスし、ミドルウェア、API エンドポイント、アクションハンドラーは context.session から取得します。ストレージは自動的には用意されません。Node、Cloudflare、Netlify の 3 つのアダプターはデフォルトドライバーを自動で選択しますが、それ以外のアダプターではセッションドライバーリファレンスに従って自分で指定します。エッジランタイムにデプロイする前に知っておくべき制約が 1 つあります。セッションはエッジミドルウェアでは動作しません。
locals に型を付けるには、src/env.d.ts で App 名前空間を拡張します。
// src/env.d.ts
type User = {
id: string;
email: string;
};
declare namespace App {
interface Locals {
user: User | null;
}
}
Astro.locals でユーザーを読み取る
ページは、そのページがオンデマンドレンダリングされている限り、ミドルウェアが代入したものを読み取れます。Astro.locals は 1 回のルートレンダリングの間しか存続しないため、ミドルウェアからページへユーザーオブジェクトを運ぶ場所としては適切ですが、次のリクエストまで残す必要があるものを保持する場所としては不適切です。
---
// 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>
1 行目を削除すると、このページはプリレンダリング対象に戻ります。静的 HTML にビルドされ、Astro.locals.user は決して設定されず、すべての訪問者が同じファイルを受け取ります。このたった 1 行が、機能する認証と、誰も通過できないログイン画面との差です。
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 はレンダリングを最初からやり直すため、その過程でミドルウェアが 2 回目の実行を行います。したがって /login は protectedPaths から除外しておかないと、2 回目のパスで同じチェックに引っかかり、ループします。この種の認証の破綻が例外を投げることはめったにありません。ログインフローのセッションリプレイを見ると、ユーザーがログインページと保護対象ルートの間を往復している様子が映ります。スコープを誤ったセッション Cookie や、誤った分岐に置かれたゲートは、外側から見るとまさにこう見えるのです。
ここではもう一方の rewrite の形式は避けてください。next() に Request を渡すと、Astro は元のリクエストから置き換え用のリクエストを構築するため、その時点以降(またはそれ以前)にボディを読み取ろうとすると実行時に例外が発生します。これが最も深刻に影響するのは、アクションが HTML フォームから駆動される場合であり、そのためドキュメントは代わりに context.rewrite() または Astro.rewrite() を使うよう誘導しています。
Astro Actions によるログインとログアウト
astro@4.15 で追加された Astro Actions は、ログインとログアウトを扱うための組み込みの手段であり、手書きの 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 キーが現行の記法です。ログイン時にセッション ID を再生成することでセッション固定攻撃を防ぎ、destroy() は Cookie を消去してサーバー上の保存データを破棄します。
deleteAccount に注目してください。アクションは独自の URL を持つ公開エンドポイントであるため、そのフォームをレンダリングするページを一度も読み込まずに、誰でも直接呼び出せます。ユーザーデータに触れるハンドラーはすべて、自分自身で 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 は不要です。
アイランドの罠: アイランドは locals を決して参照できない
Astro.locals と Astro.session はサーバーサイド専用です。ミドルウェア、.astro のページとレイアウト、API ルート、アクションハンドラーはすべてサーバー上で実行され、これらを共有します。client:* ディレクティブを持つコンポーネントはブラウザーでハイドレートされ、この連鎖の外側にあるため、ページから 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} />
壊れている方のバージョンでも例外は発生しません。ページはログイン済みのコンテンツをレンダリングしているのに、その隣のアイランドはサインインボタンを表示する、という不整合が視覚的にしか現れないのです。
この例では Astro 組み込みのセッションを使っていますが、ライブラリを使う場合も同じ手順が当てはまります。Astro の認証ガイドは、メールによるサインインや OAuth 向けに Better Auth や Clerk といった認証ライブラリを紹介しており、Better Auth のフレームワーク非依存な設計は Astro にも適用できます。詳しくはBetterAuth の概要記事をご覧ください。どれを選んでも、アダプター、プリレンダリングされないルート、そして context.locals に書き込むミドルウェアが必要であることに変わりはありません。
まとめ
Astro における認証は、先頭に 1 つの弱いリンクを抱えた連鎖です。アダプターがなく prerender = false もなければ、リクエストは存在せず、下流のすべてが静かに何もしなくなります。まずレンダリングモードを正しく設定し、次にミドルウェア、そしてアクションのハンドラーごとのチェックへ進んでください。astro.config.mjs の隣にオンデマンドレンダリングのガイドを開き、アダプターをインストールして、ユーザー情報を必要とする最初のページに export const prerender = false を追加しましょう。
FAQ
Astro Actions はプリレンダリングされたページでも動作しますか?
いいえ。フォームの action を通じてアクションを呼び出すには、ページがオンデマンドレンダリングされている必要があります。そのため、フォームを含むページに 'export const prerender = false' を追加し、ハンドラーを実行するサーバーが存在するようアダプターをインストールしてください。また、アクションのリクエストボディにはデフォルトで 1 MB(1048576 バイト)の上限があります。アップロードなど、より大きなものを受け付ける必要があるハンドラーがある場合は security.actionBodySizeLimit を引き上げてください。
プリレンダリングされた静的ページで、ログイン状態/未ログイン状態の UI を出し分けられますか?
サーバー側では不可能です。プリレンダリングされたページはビルド時にディスクへ書き出され、すべての訪問者が同一のファイルを受け取るため、分岐に使える Cookie ヘッダーが存在しません。有効な対処は 2 つあります。'export const prerender = false' でそのルートをプリレンダリングから除外するか、ページは静的なままにして、ブラウザーからオンデマンドのエンドポイントに対してユーザー情報を取得し、その結果をクライアントコンポーネントに渡す方法です。
Astro はログインフォームを CSRF から自動的に保護してくれますか?
部分的には保護されます。オンデマンドレンダリングされるページでは、Astro はブラウザーが送信する origin ヘッダーとリクエスト先の URL を比較し、両者が一致しない場合は 403 を返します。この挙動は security.checkOrigin オプションを通じて Astro 5 以降デフォルトで有効になっており、対象はクロスサイトのフォーム送信のみです。それでもログイン時にはセッション ID を再生成する必要がありますし、すべてのアクションハンドラーと API ルートを個別に認可する必要もあります。security.checkOrigin を false に設定すると、このチェックは無効になります。
未ログインのユーザーをログインページへ送るには、context.rewrite と Astro.redirect のどちらを使うべきですか?
訪問者がリクエストした URL のままログインのコンテンツを配信したい場合は、ミドルウェアで context.rewrite を使ってください。保護対象のパスがアドレスバーに残り、ブラウザーの 2 回目の往復を避けられます。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