[Astro #13] Astro SSR × Resend × JWT で作るDB不要の「マジックリンク認証」
はじめに
個人サイトの管理画面やダッシュボードを作った際、一番悩ましいのが「セキュリティ」です。
最初は簡単なパスワード認証をかけていましたが、単一のパスワードではボットによる総当たり攻撃(ブルートフォースアタック)に対して脆弱です。かといって、個人サイトのために大掛かりなデータベース(Redisなど)を用意してアクセス制限をかけるのも少し大げさです。
そこで今回は、「パスワードを完全に廃止」し、メールアドレスとJWT(JSON Web Token)を使ったマジックリンク認証を実装しました。
データベースを一切使わず、AstroのSSR(サーバーサイドレンダリング)機能とメール送信API「Resend」だけで完結する、極めてモダンでセキュアな構成です。
Resend:
Resend · Email for developers
The best way to reach humans instead of spam folders. Deliver transactional and marketing emails at scale.
resend.com前回の記事:
[Astro #12] 完全自動化!YouTube Data APIで動画リストを取得し、Astroでギャラリーを自動生成する // PROTOCOL.LAIN
毎回手動で動画を追加するのは面倒!npm run dev(またはbuild)のタイミングでYouTube APIを叩き、最新の動画リストをJSON化してAstroで表示する自動化ワークフローの備忘録です。
lain-lab.comなぜマジックリンクなのか?
マジックリンク方式(メールに届いた一時的なURLをクリックしてログインする仕組み)には、個人開発において最強クラスのメリットがあります。
- 総当たり攻撃が不可能: 入口にパスワード入力欄がないため、攻撃者は手も足も出ません。
- データベース不要: JWTを使うことで「ログインの有効期限」や「誰がログインしたか」という情報を暗号化してURLやCookieに持たせることができます。サーバーが状態(State)を記憶する必要がありません。
- 実質的な二要素認証: メールボックスを開ける端末(自分)からしかアクセスできないため、非常に堅牢です。
技術スタックと準備
- フレームワーク: Astro (v5以降 / SSRとして動作)
- メール配信: Resend
- トークン生成: jsonwebtoken
Resend · Email for developers
The best way to reach humans instead of spam folders. Deliver transactional and marketing emails at scale.
resend.com
まずは必要なパッケージをインストールします。
npm install resend jsonwebtoken
npm install @types/jsonwebtoken --save-dev
環境変数(.env またはホスティング先の環境変数設定)に以下の値を追加しておきます。
RESEND_API_KEY=re_xxxxxxxxxxxxxx # Resendで取得したAPIキー
JWT_SECRET=your_super_secret_key # JWTを署名するためのランダムで長い文字列
MY_EMAIL=your_email@example.com # ログインリンクを受け取る自分のアドレス
実装:admin.astro の全コード
Astroの特定ページだけを動的(SSR)に動かすため、ファイルの先頭で export const prerender = false; を宣言します。
この1ファイルで、「メール送信画面」「トークンの検証」「ログイン後のダッシュボード」の3役をこなします。
---
// src/pages/admin.astro
export const prerender = false; // このページだけサーバーサイドで処理する
import { Resend } from 'resend';
import jwt from 'jsonwebtoken';
// --- [環境変数の取得] ---
const RESEND_KEY = import.meta.env.RESEND_API_KEY;
const JWT_SECRET = import.meta.env.JWT_SECRET;
const MY_EMAIL = import.meta.env.MY_EMAIL;
const resend = new Resend(RESEND_KEY);
const ADMIN_PATH = '/admin';
let message = '';
let isAuthenticated = false;
// --- [1. 認証チェック (Cookie)] ---
// 既にログイン済み(有効なCookieを持っている)か確認
const authCookie = Astro.cookies.get('admin_session')?.value;
if (authCookie && JWT_SECRET) {
try {
jwt.verify(authCookie, JWT_SECRET);
isAuthenticated = true;
} catch (e) {
Astro.cookies.delete('admin_session');
}
}
// --- [2. マジックリンクの検証 (URLにトークンがある場合)] ---
// メールからリンクを踏んできたときの処理
const token = Astro.url.searchParams.get('token');
if (token && JWT_SECRET) {
try {
// トークンが本物で期限内かチェック
jwt.verify(token, JWT_SECRET);
// 成功したら、ダッシュボード閲覧用の新しいCookie(1週間有効)を発行
const sessionToken = jwt.sign({ user: 'admin' }, JWT_SECRET, { expiresIn: '7d' });
Astro.cookies.set('admin_session', sessionToken, { path: '/', httpOnly: true, maxAge: 60*60*24*7 });
// トークンなしの綺麗なURLにリダイレクト
return Astro.redirect(ADMIN_PATH);
} catch (e) {
message = 'リンクが無効か期限切れです。再度送信してください。';
}
}
// --- [3. メール送信処理 (POST時)] ---
// フォームからメールアドレスが送信されたときの処理
if (Astro.request.method === 'POST') {
const formData = await Astro.request.formData();
const email = formData.get('email');
// 自分自身のアドレスが入力された時だけ処理する(悪用防止)
if (email === MY_EMAIL && RESEND_KEY && JWT_SECRET) {
// 10分間だけ有効な一時トークンを生成
const loginToken = jwt.sign({ user: 'admin' }, JWT_SECRET, { expiresIn: '10m' });
const loginUrl = `${Astro.url.origin}${ADMIN_PATH}?token=${loginToken}`;
const { error } = await resend.emails.send({
from: 'Lain-Lab Admin <onboarding@resend.dev>', // 独自ドメイン認証後は変更可能
to: [MY_EMAIL],
subject: 'Lain-Lab Dashboard ログインリンク',
html: `<p>以下のリンクからログインしてください(10分間有効):</p><a href="${loginUrl}">${loginUrl}</a>`
});
if (error) {
console.error("Resend Error Detail:", error);
message = `送信失敗: ${error.message}`;
} else {
message = 'メールを送信しました!チェックしてください!';
}
} else {
message = '無効なリクエストです。';
}
}
// --- [4. ログアウト処理] ---
if (Astro.url.searchParams.get('logout') === 'true') {
Astro.cookies.delete('admin_session');
return Astro.redirect(ADMIN_PATH);
}
// ※ ここにダッシュボード用のデータ取得処理などを追加
---
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>Admin Auth</title>
</head>
<body style="background: #0f172a; color: white; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; font-family: sans-serif;">
{!isAuthenticated ? (
<div style="background: #1e293b; padding: 2.5rem; border-radius: 12px; text-align: center; width: 320px;">
<h1>Admin Auth</h1>
{message && <p style="color: #38bdf8;">{message}</p>}
<form method="POST">
<input type="email" name="email" placeholder="Your Email" required
style="padding: 12px; width: 100%; box-sizing: border-box; margin: 1rem 0; border-radius: 6px; background: #0f172a; color: white; border: 1px solid #334155;">
<button type="submit" style="width: 100%; padding: 12px; background: #38bdf8; color: #0f172a; border: none; border-radius: 6px; font-weight: bold; cursor: pointer;">
ログインリンクを送信
</button>
</form>
</div>
) : (
<div style="background: #1e293b; padding: 2.5rem; border-radius: 12px; text-align: center; width: 350px;">
<h1>Dashboard</h1>
<p>認証に成功しました。ここはあなたしか見られません。</p>
<a href="?logout=true" style="color: #ef4444; text-decoration: none; margin-top: 2rem; display: inline-block;">Logout</a>
</div>
)}
</body>
</html>
デプロイ先(Netlify等)での「環境変数」設定
ローカル環境で完璧に動いても、Netlifyにデプロイした直後に 「HTTP ERROR 500」 でページが真っ白になることがあります。これは、サーバーサイドで環境変数が読み込めず、ライブラリがクラッシュしているサインです。
Netlifyでの環境変数設定手順
- Environment variables の登録: Netlifyの [Site configuration] > [Environment variables] を開きます。
- .envのインポート:
「Add a variable」から「Import from a .env file」を選択。ローカルの
.envの内容をガバッと貼り付けるのが一番確実です(特にGOOGLE_PRIVATE_KEYのような複雑な文字列は手入力だと死にます)。
- 「再デプロイ」という儀式: ここが最大の罠です。 環境変数を保存しただけでは反映されません。必ず [Deploys] タブから 「Trigger deploy」 を実行して、設定を反映させた状態でビルドし直す必要があります。
これを行わないと、サーバーは「APIキーがない!」と叫び続けて500エラーを吐き続けます。
開発時のハマりどころ(Tips)
今回実装するにあたり、いくつかの罠に引っかかりました。同じ構成を試す方の参考になれば幸いです。
1. Astro v5以降の output 設定
以前は output: 'hybrid' と記述して一部をSSRにしていましたが、現在のAstroではこの記述は非推奨(削除)されています。デフォルトのまま(設定なし、または static)、サーバーで動かしたいファイルの先頭に export const prerender = false; と書くのが正解です。
2. .env の変更はサーバー再起動が必要
Astroの開発サーバーを立ち上げたまま .env を書き換えても、反映されません。APIキーを追加・変更した際は、必ず Ctrl+C でサーバーを落としてから再起動しましょう。
3. Resendの「テストモード制限」
Resendで独自ドメインを認証する前は、スパム防止のためResendに登録した自分のメールアドレス宛にしかメールが送信できません。
.env の MY_EMAIL が別のアドレスになっていると 403 Forbidden エラーで弾かれます。
4. 迷惑メールフォルダ直行問題
onboarding@resend.dev というテスト用アドレスから送信されるため、iCloudメールなどの強力なフィルターを持つメーラーでは、十中八九「迷惑メールフォルダ」に入ります。本番運用する際は、Resendのダッシュボードから独自ドメイン(DNSのTXTレコードなど)を追加して認証を済ませましょう。
おわりに
パスワードの管理から解放され、「自分のメールボックス」という最高強度の壁に認証を委任できるのは非常に快適です。Netlifyなどのサーバーレス環境とAstroのSSR、そしてResendの組み合わせは、個人サイトのダッシュボード構築において一つの最適解だと感じました。
関連リンク
マジックリンク認証の落とし穴
何が問題だった?ここからは、実際にリリースしてから発覚した、マジックリンク認証の問題点を3点上げていきます。※以下はすべてFirebase Authenticationを使用した際の内容となります。※解決策のある方や、運用でうまくカバーしている方がいらっしゃったら、ぜひコメントください。
zenn.dev
COMM_LOG: astro-13-magic-link-auth-resend-jwt