[Astro #13] Astro SSR × Resend × JWT で作るDB不要の「マジックリンク認証」

[Astro #13] Astro SSR × Resend × JWT で作るDB不要の「マジックリンク認証」

はじめに

個人サイトの管理画面やダッシュボードを作った際、一番悩ましいのが「セキュリティ」です。

最初は簡単なパスワード認証をかけていましたが、単一のパスワードではボットによる総当たり攻撃(ブルートフォースアタック)に対して脆弱です。かといって、個人サイトのために大掛かりなデータベース(Redisなど)を用意してアクセス制限をかけるのも少し大げさです。

そこで今回は、「パスワードを完全に廃止」し、メールアドレスとJWT(JSON Web Token)を使ったマジックリンク認証を実装しました。

データベースを一切使わず、AstroのSSR(サーバーサイドレンダリング)機能とメール送信API「Resend」だけで完結する、極めてモダンでセキュアな構成です。

Resend:

前回の記事:

なぜマジックリンクなのか?

マジックリンク方式(メールに届いた一時的なURLをクリックしてログインする仕組み)には、個人開発において最強クラスのメリットがあります。

  1. 総当たり攻撃が不可能: 入口にパスワード入力欄がないため、攻撃者は手も足も出ません。
  2. データベース不要: JWTを使うことで「ログインの有効期限」や「誰がログインしたか」という情報を暗号化してURLやCookieに持たせることができます。サーバーが状態(State)を記憶する必要がありません。
  3. 実質的な二要素認証: メールボックスを開ける端末(自分)からしかアクセスできないため、非常に堅牢です。

技術スタックと準備

  • フレームワーク: Astro (v5以降 / SSRとして動作)
  • メール配信: Resend
  • トークン生成: jsonwebtoken


まずは必要なパッケージをインストールします。

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での環境変数設定手順

  1. Environment variables の登録: Netlifyの [Site configuration] > [Environment variables] を開きます。
Netlifyでの環境変数設定手順 1
  1. .envのインポート: 「Add a variable」から「Import from a .env file」を選択。ローカルの .env の内容をガバッと貼り付けるのが一番確実です(特に GOOGLE_PRIVATE_KEY のような複雑な文字列は手入力だと死にます)。
Netlifyでの環境変数設定手順 2
Netlifyでの環境変数設定手順 2
  1. 「再デプロイ」という儀式: ここが最大の罠です。 環境変数を保存しただけでは反映されません。必ず [Deploys] タブから 「Trigger deploy」 を実行して、設定を反映させた状態でビルドし直す必要があります。
Netlifyでの環境変数設定手順 2

これを行わないと、サーバーは「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に登録した自分のメールアドレス宛にしかメールが送信できません。 .envMY_EMAIL が別のアドレスになっていると 403 Forbidden エラーで弾かれます。

4. 迷惑メールフォルダ直行問題

onboarding@resend.dev というテスト用アドレスから送信されるため、iCloudメールなどの強力なフィルターを持つメーラーでは、十中八九「迷惑メールフォルダ」に入ります。本番運用する際は、Resendのダッシュボードから独自ドメイン(DNSのTXTレコードなど)を追加して認証を済ませましょう。

おわりに

パスワードの管理から解放され、「自分のメールボックス」という最高強度の壁に認証を委任できるのは非常に快適です。Netlifyなどのサーバーレス環境とAstroのSSR、そしてResendの組み合わせは、個人サイトのダッシュボード構築において一つの最適解だと感じました。

関連リンク


COMM_LOG: astro-13-magic-link-auth-resend-jwt

NO DATA FOUND IN THIS SECTOR.