SupabaseのRow Level Security(RLS)を有効にした状態で、バックエンドサーバー経由で安全にデータを取得・操作する方法についてまとめます。
まずはじめに、フロントエンドから直接データを取得する方法、バックエンド経由でService Role Key(RLS無効)で取得する方法をまとめ、その後に表題の「RLSを有効にしたままバックエンド経由でデータを取得する方法」について解説します。
Supabaseとは?
Supabaseは、Firebaseのオープンソース代替として注目されているBaaS(Backend as a Service)です。PostgreSQLベースのデータベース、認証、ストレージ、リアルタイム機能を提供します。
RLS(Row Level Security)とは?
RLSとは、PostgreSQLが提供するアクセス制御機能で、ユーザーごとに行レベルでデータへのアクセスを制限できます。
Supabaseでは、RLSを有効にしてポリシーを定義することで、ログインユーザーに応じたデータ制御が可能です。
-- 例: 自分のユーザーIDに一致する行だけ読み取れるポリシー
CREATE POLICY "Users can view their own data"
ON profiles
FOR SELECT
USING (auth.uid() = id);
今回用意したデータ構成
users テーブル:Supabase Auth が自動的に作成します。
important_data テーブル:id, user_id, important_data のカラムを持ち、users テーブルの id を外部キーとしています。
important_dataに入っているデータ:
3つのデータを手動で挿入しました。2人のユーザーに対応するデータを作成しています。RLSでアクセス制御することによって、他のユーザーが作成したデータを閲覧できないようにします。
作成したRLS
alter policy "Enable users to view their own data only"
on "public"."important_data"
to authenticated
using (
(( SELECT auth.uid() AS uid) = user_id)
);
この設定により、自分以外のデータは取得できなくなります。
それではsupabaseからデータを取得していきましょう!!
方法①:フロントエンドから直接取得する
Next.jsでSupabase Authを用いた認証を実装し、ログインユーザーとしてデータを取得します(詳細な実装は公式ドキュメントを参照してください)。
例として、user_id が 2828517b-bb5c-4f02-8e83-5019f122450f のユーザーでログインした状態で以下のコードを実行します。
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
);
export const getDirectlyFromSupabase = async (): Promise<SecretData[]> => {
const { data, error } = await supabase
.from("important_data")
.select("*")
.order("created_at", { ascending: false });
if (error) {
throw new Error(error.message);
}
return data || [];
};
以下が取得したデータを表示した結果です。
.select("*") で全件取得しているにもかかわらず、自分のデータである2件のみ取得されていることが確認できます。これがRLSの効果です。
方法②の前に:JWTをフロントエンドからバックエンドへ送信し、バックエンドで検証する
方法②・③ではバックエンドを経由してSupabaseにアクセスします。その前に、**APIにアクセスするユーザーが正当であるか検証するためのJWTの取り扱い方法を解説します。**以下の3STEPで実装します。
1.フロントエンドでLocal StorageからJWTを取得
2.JWTをAPIリクエストのAuthorizationヘッダーに付与
3.バックエンドでJWTを検証
1.フロントエンドでLocal StorageからJWTを取得
Supabaseクライアントは、認証時にJWTをブラウザのLocal Storageに sb-{project-id}-auth-token というキーで保存します。取得例:
export const getToken = () => {
const storageKey = `sb-${process.env.NEXT_PUBLIC_SUPABASE_PROJECT_ID}-auth-token`;
const sessionDataString = localStorage.getItem(storageKey);
const sessionData = JSON.parse(sessionDataString || "null");
return sessionData?.access_token;
};
※ JWTをLocal Storageに保存することはXSS脆弱性の観点から推奨されないという意見もあります。これについては安全なのかわからなかったので詳しい方がいたらご教授いただきたいです。
2.JWTをAPIリクエストのAuthorizationヘッダーに付与
const response = await apiClient.get("/important-data/service-role", {
headers: {
Authorization: `Bearer ${token}`,
},
});
3.バックエンドでJWTを検証
Expressを使用したサーバー側でJWTを検証します。
const jwt = require("jsonwebtoken");
const hmacSecret = process.env.SUPABASE_JWT_SECRET;
const authMiddleware = (req, res, next) => {
const authHeader = req.headers.authorization;
if (!authHeader) return res.status(401).json({ error: "Unauthorized" });
const token = authHeader.replace("Bearer ", "");
try {
jwt.verify(token, hmacSecret);
next();
} catch (error) {
console.error(`Error parsing token: ${error.message}`);
return res.status(401).json({ error: "Unauthorized" });
}
};
これでトークンの検証ができました!このミドルウェアを通過したもののみがデータベースのアクセスをハンドルするようになります。以下では認証後の処理について書いていきます!
方法②:Service Role Keyを使って取得(RLSをバイパス)
Service Role Keyは、すべてのRLS制限を無視できる強力な鍵です。
信頼されたバックエンドでのみ利用するべきで、フロントエンドに絶対渡してはいけません。
今回の場合は自前のバックエンドサーバーで使用するのでService Role Keyの漏洩の心配はありません。いかにこの方法でアクセスするコードを示します
const supabase = createClient(
process.env.SUPABASE_URL,
process.env.SUPABASE_SERVICE_ROLE_KEY
);
const getImportantData = async (req, res) => {
/*----------------------先ほどと同じコード-----------------------*/
const { data, error } = await supabase
.from("important_data")
.select("*")
.order("created_at", { ascending: false });
if (error) return res.status(500).json({ error: error.message });
res.json(data);
};
supabaseクライアントのインスタンス化をするとき環境変数からservice role keyを使用しています。注目してもらいたいのはデータを取得するさいのコードが全く一緒だということです。さてどうなるか見ていきましょう!いかがsupabaseからデータを取得してフロントエンドから返した結果です!
全部で三件あります!!!他のユーザーのデータも取得できています!これはservice-role-keyがRLSを無効化するためです!
もちろんデータを取得するさいにuser_idで絞り込むようにすればこの問題は発生していません。なのでバックエンドサーバーを正しく実装していれば大丈夫なのです。しかしミスは起こりえます。意図しない形でデータを取得してしまうことが起きるのです。そういうときにRLSがあると最後の砦として機能してくれてリスクを抑えることが出来ます。なので可能であればRLSを有効化しておきたいのです。そこで方法3のやり方をします。
方法③:anon keyとJWTを使ってバックエンドから取得(RLS有効)
最後に、フロントエンドで取得したJWTとanon keyを使って、バックエンドからRLSを有効にしたままデータを取得する方法です。
const getImportantDataWithAnonKey = async (req, res) => {
const jwt = req.headers.authorization.split(" ")[1];
const supabase = createClient(
process.env.SUPABASE_URL,
process.env.SUPABASE_ANON_KEY,
{
accessToken: () => Promise.resolve(jwt),
}
);
const { data, error } = await supabase
.from("important_data")
.select("*")
.order("created_at", { ascending: false });
if (error) return res.status(500).json({ error: error.message });
res.json(data);
};
supabaseクライアントをインスタンス化する際にanon keyを使用します。さらに第3引数(options)にaccesstoken関数をいれます。型定義が()⇒Promiseを期待しているので() ⇒ Promise.resolve(jwt)の形で指定します。accesstokenオプションは主にsupabaseとは別のサービスで認証を行うときに使うもので非同期的にjwtを取得することができるようにするためこの型定義になっていると思います。今回の場合だともうすでにサーバー内のメモリに存在するのでこのような書き方になりました。
いかにこのルートを実行して得られたデータの表示結果を示します。今回もselect(*)しているのでRLSが効いていないと3件取得されることになりますがどうでしょう!!
成功です!ユーザーに紐づいたデータのみ取得できています。
おわりに
Supabaseは柔軟かつ強力なアクセス制御機構を提供しています。RLSの適切な設計と経路ごとの役割の理解が安全性を左右します。
Service Role Keyを使う場合でも、バックエンドでのバリデーションがしっかりしていれば問題はありません。しかし、RLSを併用することで二重の防御が可能になります。
可能な限り、RLSを有効化し、方法③のようにJWTを使ってバックエンド経由でアクセスする構成をおすすめします。
なお、Service Role Keyをフロントエンドに渡すことは絶対に避けてください。