如何用Supabase为带API Key认证的公开REST API做数据库?
解决方案:自定义持久化API Key + Supabase行级安全(RLS)适配
核心思路
放弃用Supabase原生用户JWT做API认证,转而构建独立的API Key系统,在Express后端完成API Key验证后,将租户身份传递给Supabase,让RLS规则自动做数据隔离。既实现了持久化API Key的需求,又充分利用Supabase的RLS特性。
1. 第一步:在Supabase创建API Key存储表
先在Supabase控制台建一个api_keys表,用来存储租户的API Key(必须哈希后存储,禁止明文):
CREATE TABLE api_keys ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES tenants(id), -- 关联你的租户表,需提前创建tenants表 key_hash TEXT NOT NULL, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), last_used_at TIMESTAMP WITH TIME ZONE, is_active BOOLEAN DEFAULT TRUE ); -- 开启RLS,仅允许服务角色读写(避免前端直接访问该表) ALTER TABLE api_keys ENABLE ROW LEVEL SECURITY; CREATE POLICY "Service role manages API keys" ON api_keys FOR ALL USING (auth.role() = 'service_role');
后端生成API Key时用bcrypt哈希:
import bcrypt from 'bcrypt'; import crypto from 'crypto'; // 生成新API Key逻辑 const rawApiKey = crypto.randomBytes(32).toString('hex'); // 生成32位随机API Key const keyHash = await bcrypt.hash(rawApiKey, 10); // 将keyHash和对应tenant_id存入api_keys表
2. 第二步:Express中间件验证API Key
写一个Express中间件,从请求头提取API Key,验证有效性并获取租户ID:
import { Request, Response, NextFunction } from 'express'; import { createClient } from '@supabase/supabase-js'; import bcrypt from 'bcrypt'; const supabaseService = createClient( process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY! ); export const validateApiKey = async (req: Request, res: Response, next: NextFunction) => { const authHeader = req.headers.authorization; if (!authHeader || !authHeader.startsWith('Bearer ')) { return res.status(401).json({ error: 'Missing or invalid API Key' }); } const rawApiKey = authHeader.split(' ')[1]; // 查询所有活跃的API Key记录 const { data: apiKeys, error } = await supabaseService .from('api_keys') .select('id, tenant_id, key_hash, is_active') .eq('is_active', true); if (error || !apiKeys.length) { return res.status(401).json({ error: 'Invalid API Key' }); } // 逐个对比哈希值(bcrypt无法反向查询) let validKey = null; for (const key of apiKeys) { const isMatch = await bcrypt.compare(rawApiKey, key.key_hash); if (isMatch) { validKey = key; break; } } if (!validKey) { return res.status(401).json({ error: 'Invalid API Key' }); } // 更新最后使用时间 await supabaseService .from('api_keys') .update({ last_used_at: new Date() }) .eq('id', validKey.id); // 将租户ID挂载到请求对象,供后续使用 (req as any).tenantId = validKey.tenant_id; next(); };
3. 第三步:传递租户身份给Supabase,适配RLS
有两种可靠方式将租户ID传递给Supabase,让RLS规则自动生效:
方案A:自定义签发租户JWT
用Supabase的JWT密钥,签发包含tenant_id的长期JWT(有效期可设为7天或按需调整):
import jwt from 'jsonwebtoken'; import { createClient } from '@supabase/supabase-js'; // 验证API Key后生成租户JWT const tenantJwt = jwt.sign( { tenant_id: (req as any).tenantId }, process.env.SUPABASE_JWT_SECRET!, { expiresIn: '7d' } ); // 创建带租户JWT的Supabase客户端 const supabaseTenant = createClient( process.env.SUPABASE_URL!, process.env.SUPABASE_ANON_KEY!, { global: { headers: { Authorization: `Bearer ${tenantJwt}` } } } ); // 后续查询用该客户端,RLS自动生效 const { data: purchases } = await supabaseTenant.from('purchases').select('*');
配置purchases表的RLS规则:
ALTER TABLE purchases ENABLE ROW LEVEL SECURITY; CREATE POLICY "Tenant access own purchases" ON purchases FOR ALL USING ( tenant_id = auth.jwt() ->> 'tenant_id' );
方案B:服务角色客户端+租户上下文
如果不想签发JWT,可通过存储过程设置会话级租户上下文,RLS规则读取该上下文:
// 验证API Key后设置租户上下文 await supabaseService.rpc('set_tenant_context', { tenant_id: (req as any).tenantId }); // 用服务角色客户端查询,RLS自动生效 const { data: purchases } = await supabaseService.from('purchases').select('*');
先创建set_tenant_context存储过程:
CREATE OR REPLACE FUNCTION set_tenant_context(tenant_id TEXT) RETURNS VOID AS $$ BEGIN PERFORM set_config('app.tenant_id', tenant_id, TRUE); -- TRUE表示会话级生效 END; $$ LANGUAGE plpgsql SECURITY DEFINER;
配置purchases表的RLS规则:
ALTER TABLE purchases ENABLE ROW LEVEL SECURITY; CREATE POLICY "Tenant access own purchases" ON purchases FOR ALL USING ( tenant_id = current_setting('app.tenant_id')::TEXT );
方案优势
- 无需手动在每个查询添加
WHERE tenant_id = ...,RLS自动处理权限,避免漏写导致的安全漏洞 - API Key持久化支持长期使用,同时通过哈希存储、状态开关、使用时间监控保证安全性
- 权限逻辑集中在数据库层,后端代码更简洁,完全利用Supabase的RLS特性
内容的提问来源于stack exchange,提问作者Angelica
相关产品推荐
相关产品推荐

