Supabase Authをバックエンドサーバー(express.js)を経由してoAuthを実装する方法

## 導入

この記事では、**Supabase・Node.js（Express.js）・クライアントサイドの3つを組み合わせて、OAuth認証を実装する方法**について紹介します。

クライアントから直接Supabaseを操作するのではなく、一度Expressサーバーを経由してOAuthを行う構成です。

---

## 注意点

本記事の内容は、**OAuthの基本的な仕組みについてある程度理解していること**を前提としています。

もしOAuth自体が初めてという方は、まずは基礎から学ぶことをおすすめします。以下の資料がとても参考になりました：

> https://www.youtube.com/watch?v=PKPj_MmLq5E&t=6s 

こうした資料を一通り見てからこの記事を読むと、より理解が深まると思います

## やりたいこと

まずは、今回実現したい構成と比較するために、**Supabaseの公式ドキュメントにある基本的なOAuth実装方法**を紹介します。

以下がそのシーケンス図です：
![スクリーンショット (95).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4081708/45629e78-c351-44e8-9f89-1bdc8bd80868.png)


### これだけで認証ができる！

```tsx
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単体よりもバックエンドで処理した方が柔軟で扱いやすいためです。

なお、**認証後のサーバーとの連携部分**については、[こちらの記事](https://qiita.com/NatumeSousaku/items/597e936acfae990a664e)で詳しく解説していますので、そちらをご覧ください。

## 今回やりたい構成

それでは、今回実現したい認証フローを見てみましょう。

（※認証後の処理は前述と同じなのでここでは省略します）

![スクリーンショット (94).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4081708/3bffd3e2-aecd-421c-8f47-662ad71ee9d5.png)


さきほどのフローとの違いは主に以下の2点です：

1. **最初のリクエストが自作サーバーに向いていること**
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の[公式ドキュメント](https://supabase.com/docs/guides/auth#access-token-storage)では、アクセストークンとリフレッシュトークンは**アプリケーション内で広く共有される前提で設計されている**ため、ローカルストレージでも十分安全であるとしています。

ただ、正直なところ、**自分の知識では「それを鵜呑みにしてよいのかどうか」判断がつきません**でした。またアクセス制御という点で安全でもトークンにはメールアドレス、名前、場合によっては電話番号などの個人情報がエンコードされた状態で入っています。それも考慮して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](https://github.com/gonchantech/oAuth-testing-node-app)にアップしてあるので、全体を見たい方はそちらをご確認ください。ここでは、OAuth認証リクエストに関する**ルート処理の部分だけ**抜粋して説明します。

```ts
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` も指定できます。

今回の設定では：

```js
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プロバイダから認可コードを受け取ってアクセストークンに交換し、セッションを確立する「リダイレクト先のルート処理」を見ていきます。

---

### 実装コード

```tsx
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`）を使い、

```js
await supabase.auth.exchangeCodeForSession(code);
```

を呼び出してアクセストークンやリフレッシュトークンを取得している点です。

サーバー内では自動的にトークン取得が行われないため、明示的にこのメソッドを呼び出す必要があります。

このメソッドは内部的に、前の認可リクエスト時にcookieに保存しておいた **PKCEのコードベリファイア** を利用して、認可コードとセットでSupabaseへトークン交換のリクエストを送ります。

---

### 2. 認証後のリダイレクト

トークンの交換に成功したら、ユーザーをログイン後のフロントエンド画面へリダイレクトします。

```js
res.redirect("https://localhost:3000/loged-in");
```

この時点でブラウザに `httpOnly` なセッションcookieがセットされているため、以降のAPIリクエストはセッションを利用して認証が行われます。

## まとめ

以上で、SupabaseのOAuth認証をExpress.jsサーバー経由で安全かつ効率的に実装する方法をご紹介しました。

この方法を使うことで、Supabaseの強力な認証機能を最大限に活かしつつ、フロントエンド側の依存性を抑え、セキュアなセッション管理が可能になります。

---

## ご意見をお聞かせください

ここまで私の考えや実装例をお伝えしてきましたが、正確性に自信がない点もいくつかあります。特に以下の部分について、専門的なご意見やアドバイスをいただけると大変ありがたいです。

1. このサーバーサイドを挟んだOAuthフローは、本当にSupabaseへの依存性を弱める実装と言えるのか。
2. Supabaseクライアントがデフォルトでブラウザのローカルストレージにトークンを保存していることの安全性について。
3. OAuth認証において、Cookieの`sameSite`属性を`strict`にできない認識は正しいか。また、`lax`設定はセキュリティ的に十分と言えるのか。

ぜひ忌憚のないコメントやご指摘をお待ちしております。

---

最後までお読みいただきありがとうございました。なるべく詳しく書いたため少し長くなってしまいましたが、この記事を通じてSupabaseのOAuth認証の理解が深まれば幸いです。

他の記事もぜひご覧ください！