Every SaaS product eventually needs API keys. Whether you're building a public API for third-party integrations or an API-first product where keys track usage and authenticate requests, you'll need a system that actually works.
API key management in Supabase requires a private schema for key storage, bcrypt hashing so keys stay secure even if your database leaks, custom PostgreSQL roles for scoped access, and Row Level Security policies that check permissions before granting data access. This architecture protects keys at rest while giving each key granular control over which resources it can access.
API key management is the practice of generating, storing, validating, and revoking programmatic access tokens that authenticate machine-to-machine API requests. Unlike user sessions, API keys are long-lived, scoped to specific permissions, and don't require interactive login.
What we'll build:
- Database Schema: Schema isolation to protect sensitive key data
- Key Generation: Cryptographically sound key generation with bcrypt hashing
- Permissions: Scope-based access control through controlled interfaces
- API: Lifecycle management for creating, listing, and revoking keys
When to Use This Pattern
Use API keys when:
- Third-party integrations need programmatic access to your API
- You're building an API-first product where customers authenticate via keys
- You need fine-grained, auditable access control per integration
Use Supabase Auth instead when:
- You only need user authentication (sessions are simpler)
- Access is always tied to a logged-in user, not a system
If unsure: Start with Supabase Auth. Add API keys when customers ask for programmatic access.
Prerequisites
Before starting, ensure you have:
- A Supabase project with the
pgcryptoextension enabled (Postgres 15+ recommended) - Basic familiarity with PostgreSQL Row Level Security and Supabase
- A Next.js application for the TypeScript examples (tested with Next.js 15)
- Access to your Supabase JWT secret and service role key
Tested with Supabase 2.x, PostgreSQL 15, @supabase/supabase-js 2.x, and jsonwebtoken 9.x.
Security Fundamentals
Before writing any code, let's establish the security principles that guide this implementation:
- Schema Isolation: Store sensitive API key data in a private schema with controlled access
- Least Privilege: Expose functionality through specific interfaces with explicit permission checks
- Never Store Raw Keys: Hash all API keys cryptographically—even if the database leaks, keys remain unusable
- Defense in Depth: Layer multiple security controls to protect against single points of failure
- Audit Everything: Log all key creations, usages, and revocations
Database Architecture
We start with proper schema isolation—the foundation of our security model.
The Private Schema
We create a dedicated api_keys_private schema that clients cannot access directly. All sensitive data lives here, and we expose functionality only through controlled public functions.
The API Key Role
We also create a custom PostgreSQL role called api_key. This role accesses data through RLS policies that check the scopes column, enabling granular, permission-based access to your application's entities.
Core Schema
-- Create a dedicated private schema for API key dataCREATE SCHEMA IF NOT EXISTS api_keys_private;-- API keys tableCREATE TABLE api_keys_private.api_keys ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), account_id UUID NOT NULL, name TEXT NOT NULL, key_prefix VARCHAR(7) NOT NULL, key_hash TEXT NOT NULL, scopes JSONB NOT NULL DEFAULT '[]', expires_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), last_used_at TIMESTAMPTZ, created_by UUID NOT NULL, is_active BOOLEAN NOT NULL DEFAULT true);ALTER TABLE api_keys_private.api_keys ENABLE ROW LEVEL SECURITY;CREATE POLICY "Accounts can read API Keys" ON api_keys_private.api_keys FOR SELECT TO authenticated USING (public.has_role_on_account(account_id));-- Usage logs tableCREATE TABLE api_keys_private.api_key_logs ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), api_key_id UUID REFERENCES api_keys_private.api_keys(id) ON DELETE CASCADE, endpoint TEXT NOT NULL, method TEXT NOT NULL, status_code INTEGER, ip_address TEXT, user_agent TEXT, timestamp TIMESTAMPTZ NOT NULL DEFAULT now());ALTER TABLE api_keys_private.api_key_logs ENABLE ROW LEVEL SECURITY;-- Performance indexesCREATE INDEX idx_api_keys_account_id ON api_keys_private.api_keys(account_id);CREATE INDEX idx_api_keys_key_prefix ON api_keys_private.api_keys(key_prefix);CREATE INDEX idx_api_key_logs_api_key_id ON api_keys_private.api_key_logs(api_key_id);CREATE INDEX idx_api_key_logs_timestamp ON api_keys_private.api_key_logs(timestamp);-- Lock down the private schemaREVOKE ALL ON SCHEMA api_keys_private FROM PUBLIC;REVOKE ALL ON ALL TABLES IN SCHEMA api_keys_private FROM PUBLIC;-- Grant access only to service_roleGRANT USAGE ON SCHEMA api_keys_private TO service_role;GRANT ALL ON ALL TABLES IN SCHEMA api_keys_private TO service_role;-- Create the API key roleCREATE ROLE api_key NOBYPASSRLS;GRANT api_key TO authenticator;GRANT anon TO api_key;Key design decisions:
- The private schema blocks direct client access entirely
- Only
service_rolecan touch the private tables directly - We store only hashed keys, so even a full database leak doesn't expose usable credentials
- The
key_prefixenables efficient lookups without exposing the full key (with ~500 keys, verification stays under 10ms)
Public Interface Functions
These functions provide controlled access to the API key system. They're the only way clients interact with key management.
Create API Key
Generates a new API key with specified scopes and optional expiration:
CREATE OR REPLACE FUNCTION public.create_api_key( p_account_id UUID, p_name TEXT, p_scopes JSONB, p_expires_at TIMESTAMPTZ DEFAULT NULL) RETURNS JSONB AS$$DECLARE v_is_authorized BOOLEAN; v_key TEXT; v_key_prefix TEXT; v_key_hash TEXT; v_id UUID; v_created_at TIMESTAMPTZ;BEGIN SELECT public.has_role_on_account(p_account_id) INTO v_is_authorized; IF NOT (v_is_authorized) THEN RAISE EXCEPTION 'Unauthorized: You do not have permission to create API keys for this account'; END IF; -- Generate a secure random API key v_key := 'sk_' || encode(gen_random_bytes(32), 'base64'); v_key := replace(replace(replace(v_key, '/', ''), '+', ''), '=', ''); v_key_prefix := substring(v_key, 1, 7); -- Hash using bcrypt with cost factor 12 v_key_hash := crypt(v_key, gen_salt('bf', 12)); INSERT INTO api_keys_private.api_keys ( account_id, name, key_prefix, key_hash, scopes, expires_at, created_by ) VALUES ( p_account_id, p_name, v_key_prefix, v_key_hash, p_scopes, p_expires_at, auth.uid() ) RETURNING id, created_at INTO v_id, v_created_at; -- Return the full key only once—it cannot be retrieved again RETURN jsonb_build_object( 'id', v_id, 'name', p_name, 'key', v_key, 'key_prefix', v_key_prefix, 'account_id', p_account_id, 'scopes', p_scopes, 'expires_at', p_expires_at, 'created_at', v_created_at );END;$$ LANGUAGE plpgsql SECURITY DEFINER;The function returns the full API key exactly once. Store it securely—it cannot be retrieved again.
Implementing has_role_on_account
The public.has_role_on_account function comes built into Makerkit. If you're not using Makerkit, implement your own version that checks whether the current user has permission to act on resources belonging to the given account_id.
List API Keys
Returns all API keys for an account, excluding sensitive hash data:
CREATE OR REPLACE FUNCTION public.list_api_keys(p_account_id UUID) RETURNS TABLE ( id UUID, name TEXT, key_prefix TEXT, scopes JSONB, expires_at TIMESTAMPTZ, created_at TIMESTAMPTZ, last_used_at TIMESTAMPTZ, is_active BOOLEAN, created_by UUID )AS$$BEGIN IF NOT (public.has_role_on_account(p_account_id)) THEN RAISE EXCEPTION 'Unauthorized: You do not have permission to view API keys for this account'; END IF; RETURN QUERY SELECT k.id, k.name, k.key_prefix, k.scopes, k.expires_at, k.created_at, k.last_used_at, k.is_active, k.created_by FROM api_keys_private.api_keys k WHERE k.account_id = p_account_id ORDER BY k.created_at DESC;END;$$ LANGUAGE plpgsql SECURITY DEFINER;Revoke API Key
Deactivates an API key so it can no longer authenticate requests:
CREATE OR REPLACE FUNCTION public.revoke_api_key(p_api_key_id UUID) RETURNS BOOLEAN AS$$DECLARE v_account_id UUID;BEGIN SELECT account_id INTO v_account_id FROM api_keys_private.api_keys WHERE id = p_api_key_id; IF v_account_id IS NULL THEN RAISE EXCEPTION 'API key not found'; END IF; IF NOT (public.has_role_on_account(v_account_id)) THEN RAISE EXCEPTION 'Unauthorized: You do not have permission to revoke API keys for this account'; END IF; UPDATE api_keys_private.api_keys SET is_active = FALSE WHERE id = p_api_key_id; RETURN TRUE;END;$$ LANGUAGE plpgsql SECURITY DEFINER;Design notes:
- Every function performs explicit permission checks before any operation
SECURITY DEFINERallows functions to access the private schema securely- Raw keys never leave the
create_api_keyfunction after initial generation
Authentication and Session Management
These functions handle API key validation and usage tracking.
Verify API Key
Validates an API key and returns its associated account:
CREATE OR REPLACE FUNCTION public.verify_api_key(p_api_key TEXT) RETURNS JSONB AS$$DECLARE v_key_prefix TEXT; v_key_record RECORD; v_found BOOLEAN := FALSE;BEGIN v_key_prefix := substring(p_api_key, 1, 7); FOR v_key_record IN SELECT id, key_hash, account_id, is_active, expires_at FROM api_keys_private.api_keys WHERE key_prefix = v_key_prefix AND is_active = TRUE LOOP IF crypt(p_api_key, v_key_record.key_hash) = v_key_record.key_hash THEN v_found := TRUE; IF v_key_record.expires_at IS NOT NULL AND v_key_record.expires_at < now() THEN RETURN jsonb_build_object('valid', FALSE, 'error', 'Invalid API key'); END IF; RETURN jsonb_build_object( 'valid', TRUE, 'api_key_id', v_key_record.id, 'account_id', v_key_record.account_id ); END IF; END LOOP; RETURN jsonb_build_object('valid', FALSE, 'error', 'Invalid API key');END;$$ LANGUAGE plpgsql SECURITY DEFINER;Success response:
{ "valid": true, "api_key_id": "uuid-string", "account_id": "uuid-string" }Failure response:
{ "valid": false, "error": "Invalid API key" }Log API Key Usage
Records each API key use for auditing and updates the last_used_at timestamp:
CREATE OR REPLACE FUNCTION public.log_api_key_usage( p_api_key_id UUID, p_endpoint TEXT, p_method TEXT, p_status_code INTEGER, p_ip_address TEXT, p_user_agent TEXT)RETURNS JSONB AS $$DECLARE v_current_time TIMESTAMPTZ := now(); v_log_id UUID;BEGIN UPDATE api_keys_private.api_keys SET last_used_at = v_current_time WHERE id = p_api_key_id; INSERT INTO api_keys_private.api_key_logs ( api_key_id, endpoint, method, status_code, ip_address, user_agent, timestamp ) VALUES ( p_api_key_id, p_endpoint, p_method, p_status_code, p_ip_address, p_user_agent, v_current_time ) RETURNING id INTO v_log_id; RETURN jsonb_build_object( 'success', TRUE, 'log_id', v_log_id, 'timestamp', v_current_time );EXCEPTION WHEN OTHERS THEN RETURN jsonb_build_object( 'success', FALSE, 'error', SQLERRM, 'error_code', SQLSTATE );END;$$ LANGUAGE plpgsql SECURITY DEFINER;Authorization Functions for Row Level Security
These functions power RLS policies that control what data API keys can access.
Check Scope Permissions
Determines whether the current API key has permission for a specific action on an entity:
CREATE OR REPLACE FUNCTION public.has_scope( p_entity_type TEXT, p_entity_id TEXT, p_action TEXT)RETURNS BOOLEAN AS $$DECLARE v_scopes JSONB; v_api_key_id UUID;BEGIN v_api_key_id := auth.uid(); IF v_api_key_id IS NULL THEN RETURN FALSE; END IF; SELECT scopes INTO v_scopes FROM api_keys_private.api_keys WHERE id = v_api_key_id AND is_active = TRUE AND (expires_at IS NULL OR expires_at > now()); IF v_scopes IS NULL THEN RETURN FALSE; END IF; -- Global wildcard IF v_scopes @> '[{"entity_type": "*", "entity_id": "*", "action": "*"}]' THEN RETURN TRUE; END IF; -- Entity type wildcard IF v_scopes @> jsonb_build_array(jsonb_build_object( 'entity_type', p_entity_type, 'entity_id', '*', 'action', '*' )) THEN RETURN TRUE; END IF; -- Action wildcard for entity type IF v_scopes @> jsonb_build_array(jsonb_build_object( 'entity_type', p_entity_type, 'entity_id', '*', 'action', p_action )) THEN RETURN TRUE; END IF; -- Specific entity permission IF v_scopes @> jsonb_build_array(jsonb_build_object( 'entity_type', p_entity_type, 'entity_id', p_entity_id, 'action', p_action )) OR v_scopes @> jsonb_build_array(jsonb_build_object( 'entity_type', p_entity_type, 'entity_id', p_entity_id, 'action', '*' )) THEN RETURN TRUE; END IF; RETURN FALSE;END;$$ LANGUAGE plpgsql SECURITY DEFINER;Get API Key Account ID
Retrieves the account associated with the current API key for RLS enforcement:
CREATE OR REPLACE FUNCTION public.get_api_key_account_id() RETURNS UUID AS$$DECLARE v_api_key_id UUID; v_account_id UUID;BEGIN v_api_key_id := auth.uid(); IF v_api_key_id IS NULL THEN RETURN NULL; END IF; SELECT account_id INTO v_account_id FROM api_keys_private.api_keys WHERE id = v_api_key_id AND is_active = TRUE AND (expires_at IS NULL OR expires_at > now()); RETURN v_account_id;END;$$ LANGUAGE plpgsql SECURITY DEFINER;Use this in RLS policies to verify the API key belongs to the correct account before granting access.
Granting Function Permissions
-- Authenticated users can manage their own keysGRANT EXECUTE ON FUNCTION public.create_api_key TO authenticated;GRANT EXECUTE ON FUNCTION public.list_api_keys TO authenticated;GRANT EXECUTE ON FUNCTION public.revoke_api_key TO authenticated;-- Service role handles verification and loggingGRANT EXECUTE ON FUNCTION public.log_api_key_usage TO service_role;GRANT EXECUTE ON FUNCTION public.verify_api_key TO service_role, api_key;-- API key role uses authorization functionsGRANT EXECUTE ON FUNCTION public.has_scope TO api_key;GRANT EXECUTE ON FUNCTION public.get_api_key_account_id TO api_key;Securing Application Data
Here's how to set up RLS policies for tables that API keys should access. This example uses a documents table:
CREATE TABLE IF NOT EXISTS public.documents ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), account_id UUID NOT NULL REFERENCES public.accounts(id), title TEXT NOT NULL, content TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now());ALTER TABLE public.documents ENABLE ROW LEVEL SECURITY;-- Grant access to api_key roleGRANT SELECT, INSERT, UPDATE, DELETE ON public.documents TO api_key;-- Regular user accessCREATE POLICY "Account members can access documents" ON public.documents FOR ALL TO authenticated USING (public.has_role_on_account(account_id));-- API key SELECTCREATE POLICY "API keys can read documents with proper scope" ON public.documents FOR SELECT TO api_key USING ( documents.account_id = public.get_api_key_account_id() AND public.has_scope('document', id::text, 'read') );-- API key INSERTCREATE POLICY "API keys can create documents with proper scope" ON public.documents FOR INSERT TO api_key WITH CHECK ( documents.account_id = public.get_api_key_account_id() AND public.has_scope('document', '*', 'create') );-- API key UPDATECREATE POLICY "API keys can update documents with proper scope" ON public.documents FOR UPDATE TO api_key USING ( documents.account_id = public.get_api_key_account_id() AND public.has_scope('document', id::text, 'update') );-- API key DELETECREATE POLICY "API keys can delete documents with proper scope" ON public.documents FOR DELETE TO api_key USING ( documents.account_id = public.get_api_key_account_id() AND public.has_scope('document', id::text, 'delete') );Policy pattern:
- Verify account ownership first (
get_api_key_account_id) - Then check specific permissions (
has_scope) - Separate policies for each operation type enable granular control
Apply this pattern to any table that needs API key access.
TypeScript Client Implementation
Create an authenticated Supabase client from an API key:
import { createClient } from '@supabase/supabase-js';import jwt from 'jsonwebtoken';import { Database } from '~/lib/database.types';const JWT_SECRET = process.env.SUPABASE_JWT_SECRET!;export async function getSupabaseClientWithApiKey( apiKey: string, request?: Request,) { if (!apiKey) { throw new Error('API key is required'); } // Use service role for verification and logging (SECURITY DEFINER functions) const serviceSupabase = createClient<Database>( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!, ); try { const { data, error } = await serviceSupabase.rpc('verify_api_key', { p_api_key: apiKey, }); if (error) { throw new Error(error.message || 'Failed to verify API key'); } const result = data as { account_id: string; api_key_id: string; valid: boolean; }; if (!result.valid) { throw new Error('Invalid API key'); } const token = jwt.sign( { role: 'api_key', sub: result.api_key_id }, JWT_SECRET, { expiresIn: '1h' }, ); const apiKeySupabaseClient = createClient<Database>( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, { global: { headers: { Authorization: `Bearer ${token}`, }, }, }, ); // Log the authentication (requires service role) const { error: logError } = await serviceSupabase.rpc('log_api_key_usage', { p_api_key_id: result.api_key_id, p_endpoint: 'authentication', p_method: 'AUTH', p_status_code: 200, p_ip_address: getClientIp(request) ?? 'unknown', p_user_agent: getUserAgent(request) ?? 'unknown', }); if (logError) { console.error('Failed to log API key usage:', logError); } return { supabase: apiKeySupabaseClient, accountId: result.account_id, }; } catch (error) { console.error('API key authentication error:', error); if (error instanceof Error) { throw new Error(`Authentication failed: ${error.message}`); } throw new Error('Unknown error occurred during authentication'); }}function getClientIp(request?: Request) { if (!request) return 'unknown'; const forwardedFor = request.headers.get('x-forwarded-for'); if (forwardedFor) { return forwardedFor.split(',')[0]?.trim(); } const realIp = request.headers.get('x-real-ip'); if (realIp) return realIp; return 'unknown';}function getUserAgent(request?: Request) { if (!request) return 'unknown'; return request.headers.get('user-agent');}Requirements:
- JWT Secret: Found in your Supabase project settings. Keep it secure.
- Service Role Key: Also in project settings. Never expose to clients.
- jsonwebtoken: Install with
npm install jsonwebtoken
API Key Management Utilities
These functions wrap the database RPCs for use in your application. They require an authenticated user context—call them from server actions or API routes where the user is already signed in.
import { createClient } from '@supabase/supabase-js';// Note: In production, pass an authenticated Supabase client instead of// creating a new one. These examples show the RPC signatures.export async function createApiKey({ accountId, name, scopes, expiresAt}: { accountId: string; name: string; scopes: Array<{entityType: string; entityId?: string; action: string}>; expiresAt?: Date;}) { const supabase = createClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! ); const formattedScopes = scopes.map(scope => ({ entity_type: scope.entityType, entity_id: scope.entityId || '*', action: scope.action })); const { data, error } = await supabase.rpc('create_api_key', { p_account_id: accountId, p_name: name, p_scopes: formattedScopes, p_expires_at: expiresAt ? expiresAt.toISOString() : undefined }); if (error) { throw new Error(`API key creation failed: ${error.message}`); } return data;}export async function listApiKeys(accountId: string) { const supabase = createClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! ); const { data, error } = await supabase.rpc('list_api_keys', { p_account_id: accountId }); if (error) { throw error; } return data;}export async function revokeApiKey(apiKeyId: string) { const supabase = createClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! ); const { data, error } = await supabase.rpc('revoke_api_key', { p_api_key_id: apiKeyId }); if (error) { throw error; } return data;}These functions work for authenticated users who have the appropriate permissions on the account.
Testing with pgTAP
Verify the implementation with comprehensive database tests:
begin;create extension "basejump-supabase_test_helpers" version '0.0.6';select no_plan();create or replace function tests.authenticate_as_api_key(api_key_id uuid) returns voidas$$begin perform set_config('role', 'api_key', true); perform set_config('request.jwt.claims', json_build_object('sub', api_key_id)::text, true);end;$$ language plpgsql;set local role postgres;-- Create test usersselect tests.create_supabase_user('apitest1', 'apitest1@test.com');select tests.create_supabase_user('apitest2', 'apitest2@test.com');-- Create a team accountselect makerkit.authenticate_as('apitest1');select public.create_team_account('APITest');-- Test 1: Owner can create API keyselect lives_ok( $$ select public.create_api_key( (select id from makerkit.get_account_by_slug('apitest')), 'Test API Key', '[{"entity_type": "document", "entity_id": "*", "action": "read"}]'::jsonb ) $$, 'Account owner should be able to create an API key');-- Verify creationselect results_eq( $$ select name, is_active from api_keys_private.api_keys where account_id = (select id from makerkit.get_account_by_slug('apitest')) and name = 'Test API Key' $$, $$ values ('Test API Key'::text, true::boolean) $$, 'API key should be created with correct name and active status');-- Test 2: Non-member cannot create API keyselect makerkit.authenticate_as('apitest2');select throws_ok( $$ select public.create_api_key( (select id from makerkit.get_account_by_slug('apitest')), 'Unauthorized Key', '[]'::jsonb ) $$, 'Unauthorized: You do not have permission to create API keys for this account', 'Non-member should not be able to create API keys');-- Test 3: Owner can list API keysselect makerkit.authenticate_as('apitest1');select isnt_empty( $$ select * from public.list_api_keys( (select id from makerkit.get_account_by_slug('apitest')) ) $$, 'Owner should be able to list API keys');-- Test 4: Non-member cannot list API keysselect makerkit.authenticate_as('apitest2');select throws_ok( $$ select * from public.list_api_keys( (select id from makerkit.get_account_by_slug('apitest')) ) $$, 'Unauthorized: You do not have permission to view API keys for this account', 'Non-member should not be able to list API keys');-- Test 5: Owner can revoke API keyselect makerkit.authenticate_as('apitest1');select lives_ok( $$ select public.revoke_api_key( (select id from api_keys_private.api_keys where account_id = (select id from makerkit.get_account_by_slug('apitest')) limit 1) ) $$, 'Owner should be able to revoke an API key');-- Verify revocationselect results_eq( $$ select is_active from api_keys_private.api_keys where account_id = (select id from makerkit.get_account_by_slug('apitest')) limit 1 $$, $$ values (false::boolean) $$, 'API key should be marked as inactive after revocation');-- Test 6: Non-member cannot revoke API keyselect makerkit.authenticate_as('apitest2');select throws_ok( $$ select public.revoke_api_key( (select id from api_keys_private.api_keys where account_id = (select id from makerkit.get_account_by_slug('apitest')) limit 1) ) $$, 'Account ID not found', 'Non-member should not be able to revoke API keys');-- Test 7: Create and verify a valid API keyselect makerkit.authenticate_as('apitest1');DO$$DECLARE api_key_result JSONB;BEGIN SELECT public.create_api_key( (select id from makerkit.get_account_by_slug('apitest')), 'Verification Test Key', '[{"entity_type": "document", "entity_id": "*", "action": "read"}]'::jsonb ) INTO api_key_result; ASSERT (SELECT (public.verify_api_key(api_key_result ->> 'key') ->> 'valid')::boolean), 'API key verification should succeed for valid key';END$$;-- Test 8: Invalid API key verificationselect row_eq( $$ select (public.verify_api_key('sk_invalid_key')->>'valid')::boolean $$, row (false::boolean), 'Invalid API key should fail verification');-- Test 9: Create API key with specific scopesselect lives_ok( $$ select public.create_api_key( (select id from makerkit.get_account_by_slug('apitest')), 'Scoped Key', '[ {"entity_type": "document", "entity_id": "123", "action": "read"}, {"entity_type": "document", "entity_id": "123", "action": "write"} ]'::jsonb ) $$, 'Should be able to create API key with specific scopes');-- Test 10: Verify scope checking without API key contextselect is( public.has_scope('document', '123', 'read'), false::boolean, 'has_scope should return false when not in API key context');set local role service_role;-- Test 11: Log API key usageselect lives_ok( $$ select public.log_api_key_usage( (select id from api_keys_private.api_keys where account_id = (select id from makerkit.get_account_by_slug('apitest')) limit 1), '/api/test', 'GET', 200, '127.0.0.1', 'Test User Agent' ) $$, 'Should be able to log API key usage');-- Verify log entryselect isnt_empty( $$ select * from api_keys_private.api_key_logs where api_key_id = ( select id from api_keys_private.api_keys where account_id = (select id from makerkit.get_account_by_slug('apitest')) limit 1 ) $$, 'API key usage log entry should be created');-- Test 12: Setup for RLS scope testingselect makerkit.authenticate_as('apitest1');INSERT INTO public.documents (account_id, title, content)VALUES ((select id from makerkit.get_account_by_slug('apitest')), 'Test Document', 'Content')RETURNING id;-- Create API key with specific document read scopeDO$$DECLARE v_account_id UUID := (select id from makerkit.get_account_by_slug('apitest')); v_document_id UUID := (select id from public.documents where title = 'Test Document' limit 1); api_key_result JSONB;BEGIN api_key_result := public.create_api_key( v_account_id, 'Document Read Key', jsonb_build_array(jsonb_build_object( 'entity_type', 'document', 'entity_id', v_document_id::text, 'action', 'read' )) ); PERFORM set_config('tests.api_key_id', (api_key_result ->> 'id')::text, false); PERFORM set_config('tests.account_id', v_account_id::text, false); PERFORM set_config('tests.document_id', v_document_id::text, false);END$$;set local role postgres;-- Test 13: Verify has_scope as api_key roleselect tests.authenticate_as_api_key(current_setting('tests.api_key_id')::uuid);select is( (public.has_scope('document', current_setting('tests.document_id')::text, 'read')), true, 'API Key should have read scope for the specific document');select is( (public.has_scope('document', current_setting('tests.document_id')::text, 'update')), false, 'API Key should NOT have update scope for the specific document');select is( (public.has_scope('document', uuid_generate_v4()::text, 'read')), false, 'API Key should NOT have read scope for a different document');select is( (public.has_scope('other_entity', '*', 'read')), false, 'API Key should NOT have scope for other entity types');-- Test 14: Verify RLS SELECT accessselect isnt_empty( $$ SELECT * FROM public.documents WHERE id = current_setting('tests.document_id')::uuid $$, 'API Key with read scope should be able to SELECT the document');-- Test 15: Verify RLS UPDATE restrictionUPDATE public.documentsSET title = 'Updated Title'WHERE id = current_setting('tests.document_id')::uuid;select row_eq( $$ SELECT title FROM public.documents WHERE id = current_setting('tests.document_id')::uuid $$, row ('Test Document'::text), 'API Key without write scope should NOT be able to UPDATE the document');-- Test 16: Verify RLS DELETE restrictionselect results_eq( $$ DELETE FROM public.documents WHERE id = current_setting('tests.document_id')::uuid RETURNING 1 $$, $$ SELECT 1 LIMIT 0 $$, 'API Key without delete scope should NOT be able to DELETE the document');set local role postgres;select * from finish();rollback;Example Next.js API Route
Here's a complete example showing API key creation and usage:
// app/api/api-keys/route.tsimport { NextResponse } from 'next/server';import { createApiKey, getSupabaseClientWithApiKey } from '~/lib/server/api-keys';export const POST = async () => { // Replace with actual account ID from your auth context const apiKey = await createApiKey({ accountId: 'your-account-uuid-here', name: 'Production API Key', scopes: [{ action: 'read', entityType: 'documents' }] }); return NextResponse.json(apiKey);};export const GET = async (request: Request) => { const apiKey = request.headers.get('X-API-KEY'); if (!apiKey) { return NextResponse.json({ error: 'Missing API key' }, { status: 401 }); } const client = await getSupabaseClientWithApiKey(apiKey, request); const { data } = await client.supabase.from('documents').select('*'); return NextResponse.json(data);}Testing the flow:
- Sign in to your application
- Call
POST /api/api-keysto create a key - Copy the returned key
- Make a
GET /api/api-keysrequest with theX-API-KEYheader - You should see the documents accessible to that key
Common Pitfalls
Watch out for these issues when implementing API key management:
- Don't use anon key for verification. The
verify_api_keyandlog_api_key_usagefunctions require service role access. Using anon key will silently fail. - Don't log full keys. Even in error messages, the 7-character prefix is enough for debugging. Full keys in logs are a security incident waiting to happen.
- Don't skip the key prefix index. Without
idx_api_keys_key_prefix, verification scans the entire table. With ~500 keys in production, we saw verification jump from 8ms to 200ms+ without it. - Don't forget RLS on the logs table. The
api_key_logstable contains usage patterns. Enable RLS and restrict access to service role only. - Don't expose scopes to clients. Return only
key_prefixand metadata from list operations. Never expose the full scope definition to the frontend. - Don't trust expiration alone. Always check
is_activealongsideexpires_at. Revoked keys should fail immediately, not wait for expiration.
When to Consider Alternatives
This pattern works well for most SaaS products, but consider alternatives if:
- You need rate limiting: Add a rate limiting layer (Redis, Upstash) in front of this system
- You need key rotation workflows: Build a rotation UI that creates new keys before revoking old ones
- You have >10k keys per account: The bcrypt verification loop may need optimization (consider key sharding by prefix)
For simpler use cases, Supabase Vault can store secrets without the full key management overhead.
Frequently Asked Questions
Can API keys be rotated without downtime?
How do I add rate limiting to API keys?
What happens if someone steals an API key?
Can I use this with the anon key instead of service role?
How do scopes work with wildcards?
Why use bcrypt instead of SHA-256 for hashing?
Next Steps
You now have encrypted key storage, scoped permissions via RLS policies, and audit logging. For storing additional secrets alongside API keys, see Supabase Vault.
If you're building a SaaS with Makerkit, this pattern integrates with the existing account system. The has_role_on_account function already handles team membership checks.