導入
この記事では、Supabase・Node.js(Express.js)・クライアントサイドの3つを組み合わせて、OAuth認証を実装する方法について紹介します。
クライアントから直接Supabaseを操作するのではなく、一度Expressサーバーを経由してOAuthを行う構成です。
注意点
本記事の内容は、OAuthの基本的な仕組みについてある程度理解していることを前提としています。
もしOAuth自体が初めてという方は、まずは基礎から学ぶことをおすすめします。以下の資料がとても参考になりました:
こうした資料を一通り見てからこの記事を読むと、より理解が深まると思います
やりたいこと
まずは、今回実現したい構成と比較するために、Supabaseの公式ドキュメントにある基本的なOAuth実装方法を紹介します。
これだけで認証ができる!
import { createClient } from "@supabase/supabase-js";
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
auth: {
flowType: "pkce", // デフォルトのimplicit flow ではなくpkceフローを使用
},
}
);
supabase.auth.signInWithOAuth({
provider: "google",
options: {
redirectTo: "http://localhost:3000/auth/callback",
},
});
このコードだけで、OAuthの一連の処理をすべてやってくれます。Supabaseすげー!
正確に言うと、redirectTo
で指定したURLのページ内でもう一度 Supabase クライアントを生成する必要があります。
クライアント側でSupabaseを初期化すると、URLパラメータから自動的にトークンを拾って、図の「GET /callback」以降の処理をすべてやってくれます。
通常はログイン後にSupabaseクライアントを使うページへ遷移する設計になると思うので、ユーザーがこの流れを意識することは基本的にありません。
認証後の処理について
今回は認証後の処理として、バックエンドサーバーを経由してDBにアクセスする構成を採用しています。
これは、バリデーションやデータの変換処理などを行う場合、Supabase単体よりもバックエンドで処理した方が柔軟で扱いやすいためです。
なお、認証後のサーバーとの連携部分については、こちらの記事で詳しく解説していますので、そちらをご覧ください。
今回やりたい構成
それでは、今回実現したい認証フローを見てみましょう。
(※認証後の処理は前述と同じなのでここでは省略します)
さきほどのフローとの違いは主に以下の2点です:
- 最初のリクエストが自作サーバーに向いていること
- OAuth認証後のリダイレクト先がクライアント内ではなく、サーバーであること
リダイレクトされたサーバーでは、Supabaseからセッション情報を取得し、それをクライアントに渡します。
ポイントとして重要なのは、OAuthの処理自体は必ずブラウザ(=ユーザーのクライアント)を通して行う必要があるという点です。
つまり、最初にSupabaseに問い合わせるのはサーバーではなくクライアントである必要があります。
ここまで読むと、「え、じゃあなんでわざわざこんなややこしい構成にしてるの?」と思うかもしれません。
その理由について、次の章で詳しく解説していきます。
なぜバックエンドサーバーを経由するのか
これには、主に以下の2つの理由があります。
1. Supabaseへの依存性を弱めることができる
このフローの最大の利点は、クライアント側のコードがSupabaseの存在を意識しなくてよくなるという点です。
フローを振り返ると、クライアントは最初にSupabaseへリクエストしているように見えますが、実際にはサーバーのレスポンスでSupabaseへのリダイレクトが発生しているだけです。
(OAuthはブラウザを介して実行する必要があるため、サーバーから直接Supabaseに問い合わせることはできません)
つまり、クライアントではSupabaseクライアントを使っていません。
その結果、Supabaseクライアントを使っているのはサーバー側だけという構成になります。
これはどういう意味かというと、将来的に「Supabase使うのやめたい!」となった場合でも、修正が必要なのはバックエンドのサーバーコードだけで済みます。
これはアプリケーションのスケーラビリティを考えたときに非常に重要です。
特定のSaaSやバックエンドに強く依存しすぎる構成は、長期的には変更コストが高くなります。
そのため、依存性をできるだけ局所化し、柔軟に変更できるようにすることを目的にこの構成を採用しています。
2. トークンをhttpOnlyクッキーで安全に管理できる
Supabaseはデフォルトで、アクセストークンやリフレッシュトークンをブラウザのローカルストレージに保存します。
ただし、ローカルストレージに保存されたトークンはJavaScriptからアクセスできてしまうため、XSS(クロスサイトスクリプティング)攻撃に弱いというデメリットがあります。
今回の構成では、サーバー側でトークンの取得・保存を制御できるため、よりセキュアなhttpOnlyクッキーを使ってトークンを管理できます。
httpOnlyクッキーであれば、JavaScriptからアクセスできないため、XSS耐性が高まります。
なお、Supabaseの公式ドキュメントでは、アクセストークンとリフレッシュトークンはアプリケーション内で広く共有される前提で設計されているため、ローカルストレージでも十分安全であるとしています。
ただ、正直なところ、自分の知識では「それを鵜呑みにしてよいのかどうか」判断がつきませんでした。またアクセス制御という点で安全でもトークンにはメールアドレス、名前、場合によっては電話番号などの個人情報がエンコードされた状態で入っています。それも考慮してhttpOnly Cookieに入れる判断をしました。
このあたりのセキュリティ判断について詳しい方がいれば、ぜひ教えていただけると助かります。
Supabaseをサーバーサイドで使うことへの課題
ここまでで、「どんな実装をしたいのか」「なぜその方法を選んだのか」はご理解いただけたかと思います。
ここからは、その実装をサーバーサイドで行う際に直面する課題について紹介していきます。大きく2つあります。
1. ローカルストレージが使えない
ブラウザではlocalStorage
やsessionStorage
などのWeb Storage APIが使えますが、当然ながらサーバーサイドでは使用できません。
Supabaseのクライアントライブラリはデフォルトでブラウザ環境を前提に設計されており、トークンの保存や参照もローカルストレージが使われます。
そのため、サーバーサイドでSupabaseクライアントを使おうとすると、ストレージ機能をサーバー用に設定する必要があるという課題が出てきます。
2. PKCEフローでリダイレクト先がブラウザでない場合、自動的にトークンが取得されない
先ほど紹介した最初のフロー(クライアントで完結するやつ)では、リダイレクト後にブラウザでSupabaseクライアントをインスタンス化すれば、URLパラメータから自動的に認可コードを読み取って、アクセストークンの取得までやってくれます。本当に何もしなくても勝手にやってくれる。すごい。
ただし、これが成り立つのはあくまでクライアント(ブラウザ)内でSupabaseクライアントを使っている場合に限ります。
今回のようにリダイレクト先がサーバー(Express.jsなど)の場合、SupabaseはそのURLの中身まで見て何かしてくれるわけではありません。
自分で明示的に認可コードを取得し、アクセストークンと交換する処理を書く必要があります
この2点が、サーバー経由でOAuthを実装する際に考慮しなければいけない大きな違いです。
とはいえ、これらの課題の解決策はとてもシンプルで、すでにSupabaseが必要なメソッドを提供してくれています。
ただ問題なのは、その方法が公式ドキュメントであまり説明されていないという点です。
結局、ちゃんと理解しようと思ったらソースコードを自分で読みにいく必要があります。
いざ実装!
クライアントからNodeサーバーへリクエストを送り、レスポンスでSupabaseへリダイレクトさせる
まずはコードを紹介します。すべてのコードはGitHubにアップしてあるので、全体を見たい方はそちらをご確認ください。ここでは、OAuth認証リクエストに関するルート処理の部分だけ抜粋して説明します。
const express = require("express");
const { createServerClient } = require("@supabase/ssr");
const router = express.Router();
router.get("/authorize", async (req, res) => {
try {
const SUPABASE_PUBLIC_URL = process.env.SUPABASE_PUBLIC_URL;
const SUPABASE_ANON_KEY = process.env.SUPABASE_ANON_KEY;
if (!SUPABASE_PUBLIC_URL || !SUPABASE_ANON_KEY) {
return res.status(500).json({
error: "Missing required environment variables",
message: "SUPABASE_PUBLIC_URL, SUPABASE_ANON_KEY must be set",
});
}
// ポイント①: createServerClientを使う
const supabase = createServerClient(
SUPABASE_PUBLIC_URL,
SUPABASE_ANON_KEY,
{
cookieOptions: {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
},
cookies: {
getAll() {
if (req.cookies) {
return Object.entries(req.cookies).map(([name, value]) => ({
name,
value: value || "",
}));
}
const cookieHeader = req.headers.cookie;
if (!cookieHeader) return [];
return cookieHeader.split(";").map((pair) => {
const [name, value] = pair.trim().split("=");
return {
name: name.trim(),
value: decodeURIComponent((value || "").trim()),
};
});
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) => {
res.cookie(name, value, options);
});
},
},
cookieEncoding: "base64url",
}
);
// ポイント②: URLを取得してリダイレクト
const {
data: { url },
error,
} = await supabase.auth.signInWithOAuth({
provider: "google",
options: {
redirectTo: "http://localhost:8080/api/oauth/get-token",
},
});
res.redirect(url);
} catch (error) {
console.error("OAuth authorization error:", error);
res.status(500).json({
error: "Internal server error",
message: error.message,
});
}
});
module.exports = router;
このコードのポイントは2つ!
① @supabase/ssr
の createServerClient
を使う
最初に紹介したフローでは、クライアントサイドで @supabase/supabase-js
の createClient
を使っていましたが、今回は サーバーサイドの実装 なので @supabase/ssr
の createServerClient
を使用しています。
この @supabase/ssr
パッケージは、公式では「サーバーサイドレンダリング用」と紹介されていますが、実際にはほぼ createClient
と同じ動きをします。
実際 createServerClient
が返すのも、createClient
と同じSupabaseClient
インスタンスです。
では何が違うのかというと、「サーバーで使うことを前提に、いくつかの設定を簡単にしてくれている」点です。
具体的には、セッションデータの保存先が localStorage
ではなく cookie
になるという違いが大きいです。
Supabaseの storage
オプションについて補足
createClient
は storage
というオプションで、トークンの保存先を指定できます。
この storage
を指定しないと、通常はブラウザの localStorage
に保存されます。ただし、localStorage
が使えないサーバー環境では、インスタンス内のメモリに保存されてしまいます。
この「メモリ保存」はルートごとにクライアントインスタンスが異なる場合、セッションの共有ができず問題になります。
その問題を解決するために、createServerClient
は storage
にCookieを使うように、必要な設定を手伝ってくれます。
Cookie設定の中身
createServerClient
では cookies.getAll()
と cookies.setAll()
を実装する必要があります。
-
getAll()
でリクエストヘッダからクッキーを読み取り、 -
setAll()
でレスポンスヘッダにクッキーをセットします。
また、任意で cookieOptions
や cookieEncoding
も指定できます。
今回の設定では:
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
-
httpOnly
: JSからアクセスできないようにして、XSS対策 -
secure
: 本番環境ではHTTPS通信のみで送信されるように -
sameSite: "lax"
: 認証後のリダイレクトでクッキーがブロックされないようにする(strict
だとリダイレクト時に送られない)
cookieEncoding
は "base64url"
にしておきました。
この設定によって、サーバーサイドでもトークンの保存と読み出しができるようになり、「ローカルストレージが使えない問題」が解決します。
② signInWithOAuth()
で認証URLを取得して、サーバーからリダイレクトする
signInWithOAuth()
を使ったことがある方は多いと思いますが、このメソッドには知られざる仕様があります。
- ブラウザ(クライアント)で使うと、その場でリダイレクトしてくれる
- サーバー(Node.jsなど)で使うと、リダイレクト用のURLを返してくれる
つまり、そのまま res.redirect(url)
すれば、クライアントをSupabaseの認証画面に飛ばせるというわけです。
この挙動を使って、クライアントから /authorize
にリクエスト → サーバーがURLを取得してリダイレクト、という流れを作っています。
そして、redirectTo
には 認証後のリダイレクト先(=サーバーの別ルート) を指定しています。
この処理で「クライアント → サーバー → Supabase → ユーザー認証 → サーバーに戻ってくる」という一連の流れの最初の部分が完成しました。
このあと、Supabaseが認可コードをつけてサーバーの redirectTo
に指定したルートにリダイレクトしてくれます。
その「リダイレクト先のルートの処理」を次に見ていきましょう!
OAuth認証のリダイレクト先ルートの実装
ここからは、OAuthプロバイダから認可コードを受け取ってアクセストークンに交換し、セッションを確立する「リダイレクト先のルート処理」を見ていきます。
実装コード
const express = require("express");
const { createServerClient } = require("@supabase/ssr");
const router = express.Router();
// OAuthコールバック処理のAPIルート
router.get("/get-token", async (req, res) => {
try {
// クエリパラメータからOAuth情報を取得
const { code, state, error, error_description } = req.query;
if (process.env.SUPABASE_PUBLIC_URL && process.env.SUPABASE_ANON_KEY) {
try {
// 先ほどと同じ設定でSupabaseクライアントを生成
const supabase = createServerClient(
process.env.SUPABASE_PUBLIC_URL,
process.env.SUPABASE_ANON_KEY,
{
cookieOptions: {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "none",
},
cookies: {
getAll() {
if (req.cookies) {
return Object.entries(req.cookies).map(([name, value]) => ({
name,
value: value || "",
}));
}
const cookieHeader = req.headers.cookie;
if (!cookieHeader) return [];
const cookies = [];
const cookiePairs = cookieHeader.split(";");
for (const pair of cookiePairs) {
const [name, value] = pair.trim().split("=");
if (name && value) {
cookies.push({
name: name.trim(),
value: decodeURIComponent(value.trim()),
});
}
}
return cookies;
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) => {
res.cookie(name, value, options);
});
},
},
cookieEncoding: "base64url",
}
);
if (code) {
// 認可コードを使ってトークンを交換
const { data, error: tokenError } =
await supabase.auth.exchangeCodeForSession(code);
if (tokenError) {
return res.status(400).json({
error: "Token exchange failed",
message: tokenError.message,
});
}
}
// ログイン後のページへリダイレクト
res.redirect("https://localhost:3000/loged-in");
} catch (supabaseError) {
res.status(500).json({
error: "Supabase error",
message: supabaseError.message,
});
}
} else {
res.status(500).json({
error: "Configuration error",
message: "Supabase environment variables not configured",
});
}
} catch (error) {
console.error("OAuth callback error:", error);
res.status(500).json({
error: "Internal server error",
message: error.message,
});
}
});
ポイント解説
1. 認可コードを使ったトークン交換
ここで最も重要なのは、OAuthプロバイダからのリダイレクトURLに含まれている認可コード(code
)を使い、
await supabase.auth.exchangeCodeForSession(code);
を呼び出してアクセストークンやリフレッシュトークンを取得している点です。
サーバー内では自動的にトークン取得が行われないため、明示的にこのメソッドを呼び出す必要があります。
このメソッドは内部的に、前の認可リクエスト時にcookieに保存しておいた PKCEのコードベリファイア を利用して、認可コードとセットでSupabaseへトークン交換のリクエストを送ります。
2. 認証後のリダイレクト
トークンの交換に成功したら、ユーザーをログイン後のフロントエンド画面へリダイレクトします。
res.redirect("https://localhost:3000/loged-in");
この時点でブラウザに httpOnly
なセッションcookieがセットされているため、以降のAPIリクエストはセッションを利用して認証が行われます。
まとめ
以上で、SupabaseのOAuth認証をExpress.jsサーバー経由で安全かつ効率的に実装する方法をご紹介しました。
この方法を使うことで、Supabaseの強力な認証機能を最大限に活かしつつ、フロントエンド側の依存性を抑え、セキュアなセッション管理が可能になります。
ご意見をお聞かせください
ここまで私の考えや実装例をお伝えしてきましたが、正確性に自信がない点もいくつかあります。特に以下の部分について、専門的なご意見やアドバイスをいただけると大変ありがたいです。
- このサーバーサイドを挟んだOAuthフローは、本当にSupabaseへの依存性を弱める実装と言えるのか。
- Supabaseクライアントがデフォルトでブラウザのローカルストレージにトークンを保存していることの安全性について。
- OAuth認証において、Cookieの
sameSite
属性をstrict
にできない認識は正しいか。また、lax
設定はセキュリティ的に十分と言えるのか。
ぜひ忌憚のないコメントやご指摘をお待ちしております。
最後までお読みいただきありがとうございました。なるべく詳しく書いたため少し長くなってしまいましたが、この記事を通じてSupabaseのOAuth認証の理解が深まれば幸いです。
他の記事もぜひご覧ください!