SupabaseのRow Level Security(RLS)を有効にした状態で、バックエンドサーバー経由で安全にデータを取得・操作する方法についてまとめます。
まずはじめに、フロントエンドから直接データを取得する方法、バックエンド経由でService Role Key(RLS無効)で取得する方法をまとめ、その後に表題の「RLSを有効にしたままバックエンド経由でデータを取得する方法」について解説します。
追記
最初の実装ではsupabaseのクライアントライブラリの良さを活かせない実装方法であるということに気づきました。そのため最後により良い実装について前の実装となにが変わったか、なにが優れているのかを追加したしました。
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件取得されることになりますがどうでしょう!!
成功です!ユーザーに紐づいたデータのみ取得できています。
追記:より良い実装
まずoAuthの認証をサーバーサイドに移して、トークンの管理をcookieで行うようにしました。これについては別の記事で実装方法となぜそうするかについて詳しく解説してあるので、そちらをご確認ください。ここではcookieにトークンが設定されたリクエストが来たとして実装を進めていきます。
###変更後のトークン検証
const jwt = require("jsonwebtoken");
const { createServerClient } = require("@supabase/ssr");
const hmacSecret = process.env.SUPABASE_JWT_SECRET;
const authMiddleware = async (req, res, next) => {
const supabase = createServerClient(
process.env.SUPABASE_PUBLIC_URL,
process.env.SUPABASE_ANON_KEY,
{
cookieOptions: {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
},
cookies: {
getAll() {
const cookies = Object.entries(req.cookies).map(([name, value]) => ({
name,
value: value || "",
}));
return cookies;
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) => {
res.cookie(name, value, options);
});
},
},
cookieEncoding: "base64url",
}
);
const session = await supabase.auth.getSession();
if (!session.data.session) {
console.log("Unauthorized because no session");
return res.status(401).json({ error: "Unauthorized" });
}
try {
const decoded = jwt.verify(session.data.session.access_token, hmacSecret);
req.supabase = supabase;
next();
} catch (error) {
console.log(`Error parsing token: ${error.message}`);
return res.status(401).json({ error: "Unauthorized - Invalid token" });
}
};
module.exports = authMiddleware;
変更点1: @supabase/ssrを使用してcreateClientではなくcreateServerClientをインスタンス化する。
これはさきほど紹介した別の記事でより詳しく説明していますがサーバーサイドで使用するのにより適したsupabaseClientがインスタンス化されます。
これを使うことで、サーバー側でも Supabase の認証処理を安全に実行可能です。ここでは Cookie からトークンを取得・設定するためのカスタムロジックを渡しています。
-
getAll()
:req.cookies
から Supabase が期待する形式に変換 -
setAll()
:Supabase が返す Cookie をres.cookie()
で設定 -
cookieEncoding: "base64url"
:Supabase が内部で使用するエンコーディングに一致させる必要あり
変更点2: getSession()でアクセストークンを取得してjwt sercretで検証
supabase.auth.getSession() は、自動で Cookie からトークンを読み取り、アクセストークンの期限切れ時にはリフレッシュトークンを使って再取得してくれます。
ただし、Supabase クライアントはアクセストークンの形式(JWTであるかどうか)までは検証しないため、別途 jsonwebtoken パッケージを使って内容を検証しています。
const decoded = jwt.verify(session.data.session.access_token, hmacSecret);
これは、アクセストークンの改ざんや期限切れを検出するためのセキュリティステップです。Supabase Dashboard から取得した JWT secret を使ってトークンを検証しています。
※supabaseの公式ドキュメントではサーバー内でgetSession()を使うなと書いてありますが、それはアクセストークンの信頼できないためです。ここでは検証を行うために使っているので問題ありません。
補足:getUser()
を使ったアクセストークンの検証方法も検討すべき
アクセストークンの正当性を検証する方法としては、現在 jwt.verify()
を用いて JWT Secret
で署名検証をしています。しかし、別の方法として Supabase の getUser()
を使ってアクセストークンに対応するユーザーを取得し、存在すれば有効・存在しなければ無効と判断する実装もあります。
当初は「Supabase に問い合わせが発生し、処理コストが増える」という理由で避けましたが、今は以下の理由から getUser()
を使う方が望ましいと感じています:
-
JWT Secret をサーバーに持たせる必要がなくなる
サーバー側で秘密鍵を管理することは、たとえ環境変数やキーマネージャーを使っても漏洩リスクが伴います。不要であれば、共有しないほうが安全です。
-
getUser()
の結果(ユーザー情報)をreq.user
に結びつけられる多くの場合、その後の処理でユーザー情報を使うことになります。ここで取得しておけば後続のルートでも再取得せずに済み、結果的に API 呼び出しの回数も減らせます。
以上が検証に関するより良い実装です。次はデータ取得です!
データ取得のより良い実装
const express = require("express");
const router = express.Router();
// important_dataテーブルから全データを取得するルートハンドラー(anon key版)
router.get("/important-data", async (req, res) => {
try {
const supabase = req.supabase;
// important_dataテーブルから全データを取得
const { data, error } = await supabase
.from("important_data")
.select("*")
.order("created_at", { ascending: false });
if (error) {
console.error("Supabase error with anon key:", error);
return res.status(500).json({
error: "Failed to fetch data from Supabase with anon key",
details: error.message,
});
}
console.log(`Successfully fetched ${data.length} records with anon key`);
// TypeScriptの型定義に合わせてレスポンスを整形
const formattedData = data.map((item) => ({
id: item.id,
created_at: item.created_at,
user_id: item.user_id,
secret_data: item.sercret_data,
}));
res.json(formattedData);
} catch (error) {
console.error("Unexpected error with anon key:", error);
res.status(500).json({
error: "Internal server error",
details: error.message,
});
}
});
module.exports = router;
先ほどインスタンス化したsupabaseインスタンスを使うことによってスッキリしました!これでRLSを使用して取得したデータと同じデータが取得できます!
### 何が優れているのか?
追記した実装がなぜ優れているのか触れておきます。それは、
SupabaseClientが内部的に行ってくれることをフルに活用できることです
{ accessToken: () => Promise.resolve(jwt), }
のオプションを指定するとアクセストークンを常にこれを使うというモードになり、先ほど述べた内部的に行っているアクセストークンのリフレッシュが効かなくなってしまいます。
おわりに
Supabaseは柔軟かつ強力なアクセス制御機構を提供しています。RLSの適切な設計と経路ごとの役割の理解が安全性を左右します。
Service Role Keyを使う場合でも、バックエンドでのバリデーションがしっかりしていれば問題はありません。しかし、RLSを併用することで二重の防御が可能になります。
可能な限り、RLSを有効化し、方法③のようにJWTを使ってバックエンド経由でアクセスする構成をおすすめします。
なお、Service Role Keyをフロントエンドに渡すことは絶対に避けてください。