In this comprehensive guide, we'll explore how to build a production-grade API key system in Supabase.
What's an API Key management system for? This sort of functionality is often required in modern applications, such as applications that offer a public API to grant programmatic access to their users - or API-based SaaS products where API Keys are essential to track usage and allow customers to use the product.
If you're building an application that offers an API to customers with Supabase/Postgres, this guide is for you!
We will cover the following topics:
- Database Schema: Designing a secure database architecture with schema isolation
- Key Generation: Implementing cryptographically sound key generation and validation
- Permissions: Building fine-grained permissions with controlled public interfaces
- API: Creating a complete API key lifecycle management system
1. Understanding API Key Security Fundamentals
Before we write our first line of code, let's understand the critical security principles that should guide our implementation:
Core Security Principles:
- Schema Isolation: Store sensitive API key data in a private schema with controlled access
- Least Privilege Access: Expose functionality through specific interfaces with permission checks
- Never Store Raw Keys: API keys should only be stored as cryptographic hashes
- Defense in Depth: Multiple security layers protect against single points of failure
- Audit Everything: All key creations, uses, and revocations should be logged
The core principles above are the foundation for our API key management system. Let's look at each one in more detail.
2. Creating the Schema Architecture
First, let's establish our database foundation with proper schema isolation.
Create a private schema "api_keys_private" for API key data
We will create a private schema for API key data named api_keys_private
. We'll create tables in this schema to store API keys, logs, and other data.
To expose the functionality in the private schema api_keys_private
, we will add some public functions to the public
schema, so that the Supabase Client can access the private schema through the functions in the public schema.
Creating a new Postgres role "api_key"
In addition, we'll create a role api_key
that will be used to access the private schema.
This role will be granted access to the public
schema, so that it will be able to perform the operations that we grant it using the scopes
column in the api_keys_private.api_keys
table. In this way, we can grant granular access to the entities using RLS policies.
API Keys Core schema structure
Let's begin by creating the core schema structure:
-- Step 1: Create a dedicated private schema for API key dataCREATE SCHEMA IF NOT EXISTS api_keys_private;-- Step 2: Create the tables in the private schemaCREATE 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) );CREATE 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;-- Step 3: Create indexes for performance optimizationCREATE 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);-- Step 4: 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 usage to roles that need itGRANT USAGE ON SCHEMA api_keys_private TO service_role;GRANT ALL ON ALL TABLES IN SCHEMA api_keys_private TO service_role;-- Create our API key roleCREATE ROLE api_key NOBYPASSRLS;GRANT api_key TO authenticator;GRANT anon TO api_key;
Let's take a moment to review the core schema structure:
- We create a dedicated private schema that can't be accessed directly by clients
- All sensitive data (key hashes, logs) are stored in this protected area
- We revoke all public access to the private schema to prevent unauthorized access
- Only the
service_role
can directly access the private tables - We'll expose functionality through controlled public interfaces
We don't store keys in plain text: Keys are never stored in plain text, so that even if the row gets somehow leaked, it can't be used to perform actions using the API Key.
3. Implementing Public Interface Functions
Now, let's create the public interface functions that will provide controlled access to our API key system. This is so that the Supabase Client can access the private schema through the functions in the public schema.
1. Create API Key
The create_api_key
function will create a new API key for a given account.
We will pass the following inputs to this function:
account_id
: The ID of the account to create the API key forname
: The name of the API keyscopes
: The scopes for the API keyexpires_at
: The expiration date for the API key
-- Function 1: Create API Key (accessible to authenticated users)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; -- Security check: Verify user has permission to create API keys for this account 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 the key using pgcrypto's crypt function with a strong algorithm v_key_hash := crypt(v_key, gen_salt('bf', 12)); -- Insert the new API key record 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 API key details including the full key (only shown once) 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 create_api_key
function will return the full API key, which is only shown once, so it can be safely stored by the consumer.
Implement your own "has_role_on_account" function
The public.has_role_on_account
is a function we use in the Next.js Supabase SaaS Starter Kit - so this will work out of the box if you use Makerkit. However - if you don't use Makerkit, you'll need to implement your own version of this function. Basically, given an account_id
property, you need to ensure if the current user can action the API Key whose account_id
property is passed in.
2. List API Keys
The list_api_keys
function will list all API keys for a given account. This function will only take an account ID as input, and will return a list of API keys:
-- Function 2: List API Keys for an accountCREATE 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 -- Security check: Verify user has permission to view API keys for this account 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 API keys (without sensitive hash information) 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;
3. Revoke API Key
The revoke_api_key
function will revoke an API key, so that it cannot be used to authenticate requests. This function will take an API key ID as input, and will return a boolean indicating whether the operation was successful:
-- Function 3: Revoke API KeyCREATE OR REPLACE FUNCTION public.revoke_api_key( p_api_key_id UUID) RETURNS BOOLEAN AS$$DECLARE v_account_id UUID;BEGIN -- Get the account ID for this API key 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; -- Verify user is authorized to revoke API keys 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; -- Revoke the API key UPDATE api_keys_private.api_keys SET is_active = FALSE WHERE id = p_api_key_id; RETURN TRUE;END;$$ LANGUAGE plpgsql SECURITY DEFINER;
Understanding the public API Key Functions
- Each function implements explicit permission checks before performing any operation
- We use
SECURITY DEFINER
to allow functions to access the private schema securely - The functions expose only necessary operations with controlled inputs and outputs
- Raw API keys are never stored - we generate, hash, and only return the full key once
- These public functions can be called directly from client applications
We've now implemented all the core functionality for API key management.
4. Implementing Session Management and Authentication
Let's create the functions needed for API key authentication and session management.
1. Verify API Key
The verify_api_key
function will verify an API key and return details about the key:
-- Function 5: Verify API Key (for authentication)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 -- Extract key prefix v_key_prefix := substring(p_api_key, 1, 7); -- Find potential matching keys by prefix 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 -- Check if the key matches IF crypt(p_api_key, v_key_record.key_hash) = v_key_record.key_hash THEN v_found := TRUE; -- Check if expired 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 success with key details RETURN jsonb_build_object( 'valid', TRUE, 'api_key_id', v_key_record.id, 'account_id', v_key_record.account_id ); END IF; END LOOP; -- If we get here, no matching key was found RETURN jsonb_build_object( 'valid', FALSE, 'error', 'Invalid API key' );END;$$ LANGUAGE plpgsql SECURITY DEFINER;
The verify_api_key
function will return a JSON object with the following fields:
{ valid: true; api_key_id: string; account_id: string;}
If it's invalid, the error
field will contain an error message.
{ valid: false; error: string;}
2. Log API Key Usage
The log_api_key_usage
functions is used to log API key usage. This function will take the following inputs:
p_api_key_id
: The ID of the API keyp_endpoint
: The endpoint that was accessedp_method
: The HTTP method used to access the endpointp_status_code
: The HTTP status code returned by the endpointp_ip_address
: The IP address of the clientp_user_agent
: The user agent of the client
-- Function 6: Log API Key UsageCREATE 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 last_used_at timestamp UPDATE api_keys_private.api_keys SET last_used_at = v_current_time WHERE id = p_api_key_id; -- Insert usage log 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 success status with ids RETURN jsonb_build_object( 'success', TRUE, 'log_id', v_log_id, 'timestamp', v_current_time );EXCEPTION WHEN OTHERS THEN -- Return error information RETURN jsonb_build_object( 'success', FALSE, 'error', SQLERRM, 'error_code', SQLSTATE );END;$$ LANGUAGE plpgsql SECURITY DEFINER;
Summary of API Key Functions:
- The
create_api_key
function creates a new API key and returns the full key - The
list_api_keys
function lists all API keys for an account - The
revoke_api_key
function revokes an API key, so that it cannot be used to authenticate requests - The
verify_api_key
function handles all key validation logic securely - We use cryptographic comparison to validate keys, not simple string matching
- The
log_api_key_usage
function consolidates updating and logging in one transaction - All functions include proper error handling and security checks
5. Implementing Authorization Functions for Row Level Security
Next, let's implement the functions that will power our Row Level Security policies:
1. Checking the scope of an API Key
The has_scope
function will check if a user has permission to perform an action on a specific entity:
-- Function 7: Has Scope (for RLS policies)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 -- Get API key ID from session variable v_api_key_id := auth.uid(); IF v_api_key_id IS NULL THEN RETURN FALSE; END IF; -- Get scopes for the current API key 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; -- Check for global wildcard IF v_scopes @> '[{"entity_type": "*", "entity_id": "*", "action": "*"}]' THEN RETURN TRUE; END IF; -- Check for 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; -- Check for specific action wildcard IF v_scopes @> jsonb_build_array(jsonb_build_object( 'entity_type', p_entity_type, 'entity_id', '*', 'action', p_action )) THEN RETURN TRUE; END IF; -- Check for 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;
The has_scope
function takes the following inputs:
p_entity_type
: The type of entity that the action is being performed onp_entity_id
: The ID of the entity that the action is being performed onp_action
: The action being performed on the entity
The function will return true
if the user has permission to perform the action on the entity and false
otherwise.
2. Get API Key Account ID
The get_api_key_account_id
function will retrieve the account ID for an API key. This will be used to enforce RLS policies:
-- Function 8: Get API Key Account ID (for RLS policies)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; -- Get account_id for this API key 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;
How to use the get_api_key_account_id
function
When applying RLS policies, we need to know the account ID for the API key that is making the request. This function will retrieve the account ID from the session variable if it exists, otherwise it will look up the account ID from the API key ID.
Once retrieved the account ID, we can verify who the API key belongs to and enforce RLS policies accordingly:
- if
account_id
is the ID of the authenticated user, you can checkaccount_id
against theauth.uid()
function to verify that the API key belongs to the authenticated user - if
account_id
is the ID of row (ex.public.teams
), you can checkaccount_id
against the primary key of the row to verify that the API key belongs to the row
4. Granting Permissions to Functions
Let's grant permissions to the functions we just created so that they can be executed by the roles who are authorized to use them:
-- Grant execute permissions on all functionsGRANT 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;GRANT EXECUTE ON FUNCTION public.log_api_key_usage TO service_role;GRANT EXECUTE ON FUNCTION public.verify_api_key TO service_role, api_key;GRANT EXECUTE ON FUNCTION public.has_scope TO api_key;GRANT EXECUTE ON FUNCTION public.get_api_key_account_id TO api_key;
Authenticated users have the following permissions:
create_api_key
: Create a new API keylist_api_keys
: List all API keys for an accountrevoke_api_key
: Revoke an API key
Service roles (e.g. clients created using a service role key) have the following permissions:
log_api_key_usage
: Log API key usageverify_api_key
: Verify API key
API keys have the following permissions:
has_scope
: Check if a user has permission to perform an action on a specific entityget_api_key_account_id
: Get the account ID for an API key
5. Securing Application Data with API Key Access
Now, let's set up Row Level Security for your application data tables.
In the example below, we'll use the documents
table as an example. The documents
table belongs to a row public.accounts(id)
. In your case, this may differ.
-- Example entity tableCREATE 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());-- Enable RLSALTER TABLE public.documents ENABLE ROW LEVEL SECURITY;-- Grant specific access to api_key role (principle of least privilege)GRANT SELECT, INSERT, UPDATE, DELETE ON public.documents TO api_key;-- Regular user access policyCREATE POLICY "Account members can access documents" ON public.documents FOR ALL TO authenticated USING ( public.has_role_on_account(account_id) );-- API key access policy for SELECT operationsCREATE POLICY "API keys can read documents with proper scope" ON public.documents FOR SELECT TO api_key USING ( -- Check account ownership first documents.account_id = public.get_api_key_account_id() AND -- Check for read permission public.has_scope('document', id, 'read') );-- API key access policy for INSERT operationsCREATE POLICY "API keys can create documents with proper scope" ON public.documents FOR INSERT TO api_key WITH CHECK ( -- Check account ownership first documents.account_id = public.get_api_key_account_id() AND -- Check for create permission public.has_scope('document', '*', 'create') );-- API key access policy for UPDATE operationsCREATE POLICY "API keys can update documents with proper scope" ON public.documents FOR UPDATE TO api_key USING ( -- Check account ownership first documents.account_id = public.get_api_key_account_id() AND -- Check for update permission public.has_scope('document', id, 'update') );-- API key access policy for DELETE operationsCREATE POLICY "API keys can delete documents with proper scope" ON public.documents FOR DELETE TO api_key USING ( -- Check account ownership first documents.account_id = public.get_api_key_account_id() AND -- Check for delete permission public.has_scope('document', id, 'delete') );
Let's break down the policies we just created:
- RLS policies use our public authorization functions to enforce permissions
- Each operation type (SELECT, INSERT, etc.) has its own policy for fine-grained control
- We first verify account ownership before checking specific permissions
- The
has_scope
function evaluates permission based on entity type, ID, and action - This pattern can be repeated for all your application tables that need API key access
6. Implementing A Supabase Client for API Key Authentication
Let's update our TypeScript authentication function to use our new schema architecture.
We will need:
- JWT Secret - This is a secret that will be used to sign our JWT tokens. It's in your Supabase project settings. Keep it secret!
- Json web Token - A library that will be used to sign and verify JWT tokens. Install it in your project to use it.
import { createClient } from '@supabase/supabase-js';import jwt from 'jsonwebtoken';import { Database } from '~/lib/database.types';const JWT_SECRET = process.env.SUPABASE_JWT_SECRET!;// Authenticate and get a connection with the API keyexport async function getSupabaseClientWithApiKey( apiKey: string, request?: Request,) { if (!apiKey) { throw new Error('API key is required'); } const adminSupabase = createClient<Database>( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, ); try { // Use our verify_api_key function const { data, error } = await adminSupabase.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', }, ); // a Supabase client that uses the API key to authenticate const apiKeySupabaseClient = createClient<Database>( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, { global: { headers: { Authorization: `Bearer ${token}`, }, }, }, ); // Log API key usage and update last_used_at const { error: logError } = await adminSupabase.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'); }}// Helper functions to get client IP and user agentfunction getClientIp(request?: Request) { if (!request) return 'unknown'; // Check for forwarded IP (if behind proxy) const forwardedFor = request.headers.get('x-forwarded-for'); if (forwardedFor) { // Extract the first IP if multiple are present return forwardedFor.split(',')[0]?.trim(); } // Try to get from other common headers const realIp = request.headers.get('x-real-ip'); if (realIp) return realIp; // Default fallback return 'unknown';}function getUserAgent(request?: Request) { if (!request) return 'unknown'; return request.headers.get('user-agent');}
7. API Key Management
Now we can create a set of utility functions to manage API keys.
We will create the following functions:
createApiKey
: Create a new API keylistApiKeys
: List all API keys for an accountrevokeApiKey
: Revoke an API key
These functions can be called directly by authed users (either client or server side) if they have permissions to perform the action (we assigned these permissions when we created the RLS policies).
// Function to create a new API keyexport 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! ); try { // Format scopes for storage const formattedScopes = scopes.map(scope => ({ entity_type: scope.entityType, entity_id: scope.entityId || '*', action: scope.action })); // Call our database function to create the API key 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 error; } return data; } catch (error) { console.error('API key creation error:', error); if (error instanceof Error) { throw new Error(`API key creation failed: ${error.message}`); } throw new Error('Unknown error occurred during API key creation'); }}// Function to list all API keys for an accountexport async function listApiKeys(accountId: string) { const supabase = createClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! ); try { const { data, error } = await supabase.rpc('list_api_keys', { p_account_id: accountId }); if (error) { throw error; } return data; } catch (error) { console.error('Failed to list API keys:', error); throw error; }}// Function to revoke an API keyexport async function revokeApiKey(apiKeyId: string) { const supabase = createClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! ); try { const { data, error } = await supabase.rpc('revoke_api_key', { p_api_key_id: apiKeyId }); if (error) { throw error; } return data; } catch (error) { console.error('Failed to revoke API key:', error); throw error; }}
8. PgTap Tests
Now, let's write some PgTap tests to verify our API key functionality.
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 account for API key testsselect makerkit.authenticate_as('apitest1');select public.create_team_account('APITest');-- Test API Key Creation-- 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 the API key was created with correct propertiesselect 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 API Key Listing-- 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 API Key Revocation-- 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 the API key was revokedselect 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');-- Test fails because the user cannot even read the accountselect 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 API Key Verification-- Test 7: Create and verify a valid API keyselect makerkit.authenticate_as('apitest1');-- Store the API key for verificationDO$$ 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; -- Verify the created key 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 API Key Scopes-- 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 functionselect 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 API Key Usage Logging-- 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 the log entry was createdselect 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 API Key Scopes & RLS-- Test 12: Setup for scope testingselect makerkit.authenticate_as('apitest1');-- Create a document for testing RLSINSERT INTO public.documents (account_id, title, content)VALUES ((select id from makerkit.get_account_by_slug('apitest')), 'Test Document', 'Content')RETURNING id;-- Create an 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' )) ); -- Store key_id and account_id in temporary config for later use 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 14: Verify has_scope function 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 15: Verify RLS SELECT access as api_key roleselect 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 16: Verify RLS UPDATE restriction as api_key roleUPDATE 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 17: Verify RLS DELETE restriction as api_key roleselect 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' );-- Reset role for cleanupset local role postgres;select *from finish();rollback;
9. Example API with Next.js
For the sake of testing the system quickly, let's create an example API with Next.js that uses our API key functionality.
We create a route at api/api-keys
:
import { NextResponse } from 'next/server';import { createApiKey, getSupabaseClientWithApiKey } from '~/lib/server/api-keys';export const POST = async () => { const apiKey = await createApiKey({ accountId: '<account-id>', name: '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) { throw new Error('Missing API key'); } const client = await getSupabaseClientWithApiKey(apiKey, request); const { data } = await client.supabase.from('documents').select('*'); return NextResponse.json(data);}
Sign in to your web application and launch an API request at api/api-keys
using:
fetch(`/api/api-keys`).then(console.log);
The console will log the API key details. Grab the API Key, and launch another request perhaps from an HTTP Client like Postman:
GET http://localhost:3001/api/api-keysX-API-KEY: <api-key>
Use the header X-API-KEY
to pass the API key to the API and launch the request. Make sure the documents
table has at least one row. You should now see all the documents that the API key has access to! Yay!
10. Security Best Practices for API Key Management
Follow these essential security practices for your API key system:
- Schema Isolation: Store sensitive API key data in a private schema with controlled access
- Never Store Raw Keys: Always hash API keys before storing them
- Implement Key Rotation: Encourage regular rotation of API keys
- Set Expiration Dates: Consider making keys expire automatically
- Use HTTPS Only: Never transmit API keys over unencrypted connections
- Limit Key Permissions: Apply the principle of least privilege
- Log All Activities: Keep comprehensive logs for security auditing
Conclusion: Building a Production-Grade API Key System
By implementing the patterns in this guide, you've created a production-grade API key system in Supabase that rivals commercial authentication providers while maintaining complete control over your infrastructure.
This approach strikes the perfect balance between security, usability, and maintainability, providing your users with a robust API key system that they can trust with their most sensitive operations.