1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Supabase × Resendのメール送信テストで詰まった話――403と401を切り分けて配信確認するまで

1
Posted at

はじめに

予約管理Webアプリを個人開発しています。

初期版では、予約する人にアカウント登録を求めず、メールから予約の確認・変更ができる構成にしました。そのため、メール配信は欠かせない機能です。

テスト段階の配信サービスとしてResendを採用し、Supabase Edge Functionsから送信することにしました。ところが、最初の1通を送るまでに少し詰まりました。

  • テスト用Edge Functionの認証処理で拒否
  • その問題を解消すると、今度はResend側で認証エラー

検証日:2026/10/25 以下は今回の環境で観測した事実です。Supabaseのすべての環境で同じ挙動になることを保証しません。

今回のテスト構成

予約処理全体を動かす前に、宛先と本文を固定した小さな送信テスト用の関数を用意しました。

  1. Supabase管理画面の「Test」からEdge Functionを呼ぶ
  2. Edge Functionで呼び出し元を認証する
  3. Secretsに保存した RESEND_API_KEY を使い、Resend APIへ送信する
  4. Resend管理画面で配信結果を確認する

宛先は開発者本人のテスト用アドレスです。独自ドメインはまだ取得しておらず、今回は onboarding@resend.dev を送信元に使いました。

予約データの作成や、予約確認リンクの操作は、このテストには含めていません。

1. Resendに届く前で停止

ResendのAPIキーをSupabaseのSecretsに登録し、テスト関数を呼ぶと、次の応答が返りました。

403 Forbidden

この時点では、Resendのキーが間違っているようにも見えます。しかし、実際に拒否していたのは、こちらで作ったEdge Functionの認証処理でした。

// 当初の実装。今回の環境では、この照合方法で詰まった。
const key = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY');

if (!key || req.headers.get('authorization') !== `Bearer ${key}`) {
  return new Response('Forbidden', { status: 403 });
}

受信した認証ヘッダーと、関数内の環境変数を文字列として完全一致させています。この条件で拒否されると、その後のResendへの送信処理は実行されません。

つまり、ここで RESEND_API_KEY を何度入れ直しても、まだ有効性を確かめられません。

二つのキーを区別する

今回のテストには、役割の異なる二つのキーが登場しました。

キー 用途 設定先
Supabaseの管理者用キー テスト関数の呼び出し認証 テスト画面の Authorization ヘッダー
ResendのAPIキー Edge FunctionからResendへの送信認証 Supabase Secretsの RESEND_API_KEY

どちらも「APIキー」と呼ばれるので混同しやすい部分です。どのサービスへ、何のために渡すキーなのかを明確にする必要があります。

2. 認証の不一致を調べる

ヘッダーには、次の形式で入力しました。

Authorization: Bearer <Supabaseのservice_roleキー>

それでも403が続いたため、確認結果を返す一時的な診断処理を追加しました。

調べたのは、次のような情報です。

  • サーバー側の環境変数は存在するか
  • Authorization ヘッダーが届いているか
  • Bearer で始まっているか
  • ヘッダー値が重複していないか
  • 受信したJWTのroleとプロジェクト識別情報が想定と合っているか
  • 受信した値と環境変数の値が一致するか

結果は次のとおりでした。

確認内容 結果
サーバー側のキーが存在する はい
認証ヘッダーが届いている はい
Bearer の書式になっている はい
ヘッダー値が重複している いいえ
受信したJWTのrole service_role
受信したJWTのプロジェクト識別情報 対象プロジェクトと一致
環境変数の値がJWT形式である いいえ(3区切りの形式判定)
両者の文字列が一致する いいえ

今回の実行環境では、「画面から取得したJWT形式のキー」と「関数内の環境変数」が同じ文字列ではありませんでした。

ただし、これだけで環境変数の値の正体や、不一致が生じたプラットフォーム側の理由まで断定したわけではありません。わかったのは、今回のテスト関数が置いていた完全一致の前提が成立していないということです。

また、JWTの中身をデコードしてroleを読むことと、そのJWTの署名・有効性を検証することは別です。service_role と書かれているだけで認証を通す処理にはしていません。

3. テスト関数の認証方法を修正する

今回は、テスト関数の認証を次の流れに変更しました。

  1. 受信トークンのroleとプロジェクト識別情報を確認する
  2. 同じトークンを使い、対象プロジェクトのSupabase Auth管理APIへ問い合わせる
  3. 固定したテストユーザーの取得がHTTP 200で成功した場合だけ、送信処理へ進む

使ったのは、次の管理APIです。

GET /auth/v1/admin/users/<テストユーザーID>

そのリクエストの Authorization と apikey に受信トークンを渡し、Supabase側で管理者権限を検証させました。レスポンス本文のユーザー情報は利用せず、破棄しました。通信失敗や200以外の応答では認証を拒否する構成です。

Edge Functionの verify_jwt は有効のままにし、送信先・件名・本文も固定したままです。

これは、今回の接続テストを進めるための限定的な対応です。固定ユーザーの存在や管理APIへの通信に依存するため、一般的なメール配信ワーカーの認証方式として、そのまま採用することは想定していません。本来のワーカーでは、呼び出し元に応じた認証を別途設計します。

この修正によって、Supabase側の認証を通過し、ようやくResendへの送信処理に到達しました。

4. Resendの401

認証修正後、送信処理から次の結果が返りました。

{
  "status": "rejected",
  "httpStatus": 401,
  "errorCode": "validation_error"
}

これはResendから返されたHTTPステータスとエラー名を、こちらのテスト関数で必要な項目だけに整形したものです。Resendの生のレスポンスをそのまま掲載したものではありません。

先ほどの403とは、エラーを返している場所が違います。

段階 拒否した場所 今回確認できたこと
403 自作したEdge Functionの認証処理 Resendへの送信処理に進めていない
401 Resend API 送信処理には到達したが、Resendの認証が通らない

要は手前の問題が解消され、次の段階を検証できるようになったということでした。

キーのコピーと保存をやり直す

作業中、ブラウザからのコピーが期待どおりに動かず、値を反映できていなかったことがありました。その後の再設定でも401が続いたため、Resendのキーを改めて発行しました。

Secretsへ保存するのは、re_ から始まるキーそのものです。Bearer や引用符は付けません。

なお、保存済みの旧キーの値を読み出して比較したわけではないため、401の原因を「特定の文字の欠落」「空白の混入」などにまで絞り込めたわけではありません。新規発行したキーの保存後に成功したことが、今回確認できた事実です。

5. APIの受付と、配信結果を別々に確認する

再設定後のテストでは、次の応答を得られました。

{
  "status": "accepted",
  "messageId": "<Resendが返したメールID>"
}

この accepted は、Resendが送信要求を受け付けたことを表す、アプリ側のステータスです。

さらに、Resend管理画面の「Emails」で対象メールを確認し、Delivered になっていることを確かめました。

  • accepted:メール配信サービスが送信要求を受け付けた
  • Delivered:配信先メールサーバーへの配信が成功した

今回の学び

エラーコードだけでなく、返した場所を確認する

画面上に403や401が出ても、それだけではどのサービスが拒否したのかわかりません。

「関数の呼び出し」「自作コード内の認証」「外部APIの認証」「メール配信」を分けて確認すると、見直すべき設定がはっきりしました。

設定の保存と、有効性の確認を分ける

Secretsに項目が存在すること、値が正しいこと、その値で認証に成功することは、それぞれ別です。保存できたという表示だけで、接続確認まで終わったことにはできません。

秘密値そのものをログに出さない

今回の調査では、値の存在、書式、比較結果などを使って切り分けました。キー全文をログに出さなくても、かなりの範囲を調べられます。

一時的な診断処理も、調査後は整理が必要です。今回も認証方法の修正時に、途中で追加した比較結果の応答を外しました。

AIの提案も、前提を確認する

最初の完全一致チェックは、AIと作業する中で用意した実装でした。入力したキーが違う可能性を先に疑いましたが、実際には実装側の前提も確認する必要がありました。

メール送信部分を分離しておくと調査しやすい

今回は将来の配信サービス変更も考え、予約処理とメール配信サービスへの接続処理を分けていました。その接続処理を小さなテスト関数から使うことで、予約データを変更せずに送信だけを検証できました。

最初の1通で想定以上に時間がかかりましたが、「どこまで成功しているか」を一段ずつ確かめることが、結果的には解決につながりました。

参考資料

本記事のコードや診断結果は今回の検証記録です。実際の導入では、利用するキーの種類と認証方式について、最新の公式ドキュメントを確認してください。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?