API Key Authentication

Secure your API endpoints with API key authentication.

Database Schema#

1// prisma/schema.prisma 2model ApiKey { 3 id String @id @default(cuid()) 4 name String 5 key String @unique 6 keyPrefix String // First 8 chars for display 7 keyHash String // SHA-256 hash for lookup 8 userId String 9 user User @relation(fields: [userId], references: [id], onDelete: Cascade) 10 11 scopes String[] @default([]) 12 rateLimit Int @default(1000) // requests per hour 13 14 lastUsedAt DateTime? 15 expiresAt DateTime? 16 createdAt DateTime @default(now()) 17 updatedAt DateTime @updatedAt 18 19 @@index([keyHash]) 20 @@index([userId]) 21}

API Key Generation#

1// lib/api-keys.ts 2import { randomBytes, createHash } from 'crypto'; 3import { prisma } from '@/lib/prisma'; 4 5const API_KEY_PREFIX = 'bs_'; // bootspring_ 6const KEY_LENGTH = 32; 7 8export function generateApiKey(): { key: string; hash: string; prefix: string } { 9 const randomPart = randomBytes(KEY_LENGTH).toString('base64url'); 10 const key = `${API_KEY_PREFIX}${randomPart}`; 11 const hash = hashApiKey(key); 12 const prefix = key.slice(0, 11); // "bs_" + first 8 chars 13 14 return { key, hash, prefix }; 15} 16 17export function hashApiKey(key: string): string { 18 return createHash('sha256').update(key).digest('hex'); 19} 20 21export async function createApiKey( 22 userId: string, 23 name: string, 24 options?: { 25 scopes?: string[]; 26 rateLimit?: number; 27 expiresAt?: Date; 28 } 29) { 30 const { key, hash, prefix } = generateApiKey(); 31 32 const apiKey = await prisma.apiKey.create({ 33 data: { 34 name, 35 key: prefix, // Store only prefix for display 36 keyPrefix: prefix, 37 keyHash: hash, 38 userId, 39 scopes: options?.scopes || [], 40 rateLimit: options?.rateLimit || 1000, 41 expiresAt: options?.expiresAt, 42 }, 43 }); 44 45 // Return the full key only once - user must save it 46 return { 47 ...apiKey, 48 key, // Full key - only returned on creation 49 }; 50} 51 52export async function validateApiKey(key: string) { 53 if (!key.startsWith(API_KEY_PREFIX)) { 54 return null; 55 } 56 57 const hash = hashApiKey(key); 58 59 const apiKey = await prisma.apiKey.findUnique({ 60 where: { keyHash: hash }, 61 include: { 62 user: { 63 select: { 64 id: true, 65 email: true, 66 name: true, 67 }, 68 }, 69 }, 70 }); 71 72 if (!apiKey) { 73 return null; 74 } 75 76 // Check expiration 77 if (apiKey.expiresAt && apiKey.expiresAt < new Date()) { 78 return null; 79 } 80 81 // Update last used 82 await prisma.apiKey.update({ 83 where: { id: apiKey.id }, 84 data: { lastUsedAt: new Date() }, 85 }); 86 87 return apiKey; 88} 89 90export async function revokeApiKey(id: string, userId: string) { 91 return prisma.apiKey.delete({ 92 where: { id, userId }, 93 }); 94} 95 96export async function listApiKeys(userId: string) { 97 return prisma.apiKey.findMany({ 98 where: { userId }, 99 select: { 100 id: true, 101 name: true, 102 keyPrefix: true, 103 scopes: true, 104 rateLimit: true, 105 lastUsedAt: true, 106 expiresAt: true, 107 createdAt: true, 108 }, 109 orderBy: { createdAt: 'desc' }, 110 }); 111}

API Route Middleware#

1// lib/api-auth.ts 2import { NextRequest, NextResponse } from 'next/server'; 3import { validateApiKey } from './api-keys'; 4import { checkRateLimit } from './rate-limit'; 5 6export interface ApiContext { 7 apiKey: { 8 id: string; 9 name: string; 10 scopes: string[]; 11 }; 12 user: { 13 id: string; 14 email: string; 15 name: string | null; 16 }; 17} 18 19export function withApiKeyAuth( 20 handler: (req: NextRequest, ctx: ApiContext) => Promise<Response>, 21 options?: { 22 requiredScopes?: string[]; 23 } 24) { 25 return async (req: NextRequest) => { 26 // Extract API key from header 27 const authHeader = req.headers.get('authorization'); 28 const apiKeyHeader = req.headers.get('x-api-key'); 29 30 let key: string | null = null; 31 32 if (authHeader?.startsWith('Bearer ')) { 33 key = authHeader.slice(7); 34 } else if (apiKeyHeader) { 35 key = apiKeyHeader; 36 } 37 38 if (!key) { 39 return NextResponse.json( 40 { error: 'Missing API key' }, 41 { status: 401 } 42 ); 43 } 44 45 // Validate API key 46 const apiKey = await validateApiKey(key); 47 48 if (!apiKey) { 49 return NextResponse.json( 50 { error: 'Invalid API key' }, 51 { status: 401 } 52 ); 53 } 54 55 // Check required scopes 56 if (options?.requiredScopes) { 57 const hasScopes = options.requiredScopes.every((scope) => 58 apiKey.scopes.includes(scope) 59 ); 60 61 if (!hasScopes) { 62 return NextResponse.json( 63 { error: 'Insufficient permissions' }, 64 { status: 403 } 65 ); 66 } 67 } 68 69 // Check rate limit 70 const rateLimitResult = await checkRateLimit(apiKey.id, apiKey.rateLimit); 71 72 if (!rateLimitResult.success) { 73 return NextResponse.json( 74 { error: 'Rate limit exceeded' }, 75 { 76 status: 429, 77 headers: { 78 'X-RateLimit-Limit': String(apiKey.rateLimit), 79 'X-RateLimit-Remaining': '0', 80 'X-RateLimit-Reset': String(rateLimitResult.resetAt), 81 'Retry-After': String(rateLimitResult.retryAfter), 82 }, 83 } 84 ); 85 } 86 87 // Build context 88 const ctx: ApiContext = { 89 apiKey: { 90 id: apiKey.id, 91 name: apiKey.name, 92 scopes: apiKey.scopes, 93 }, 94 user: apiKey.user, 95 }; 96 97 // Call handler with rate limit headers 98 const response = await handler(req, ctx); 99 100 // Add rate limit headers 101 const newResponse = new Response(response.body, response); 102 newResponse.headers.set('X-RateLimit-Limit', String(apiKey.rateLimit)); 103 newResponse.headers.set('X-RateLimit-Remaining', String(rateLimitResult.remaining)); 104 newResponse.headers.set('X-RateLimit-Reset', String(rateLimitResult.resetAt)); 105 106 return newResponse; 107 }; 108}

Rate Limiting#

1// lib/rate-limit.ts 2import { Redis } from '@upstash/redis'; 3 4const redis = new Redis({ 5 url: process.env.UPSTASH_REDIS_REST_URL!, 6 token: process.env.UPSTASH_REDIS_REST_TOKEN!, 7}); 8 9export async function checkRateLimit( 10 identifier: string, 11 limit: number, 12 windowMs: number = 3600000 // 1 hour 13) { 14 const now = Date.now(); 15 const windowStart = now - windowMs; 16 const key = `ratelimit:${identifier}`; 17 18 // Remove old entries and count current 19 const pipe = redis.pipeline(); 20 pipe.zremrangebyscore(key, 0, windowStart); 21 pipe.zadd(key, { score: now, member: `${now}:${Math.random()}` }); 22 pipe.zcard(key); 23 pipe.expire(key, Math.ceil(windowMs / 1000)); 24 25 const results = await pipe.exec(); 26 const count = results[2] as number; 27 28 if (count > limit) { 29 const oldestInWindow = await redis.zrange(key, 0, 0, { withScores: true }); 30 const resetAt = oldestInWindow[0] 31 ? Math.ceil((oldestInWindow[0].score + windowMs) / 1000) 32 : Math.ceil((now + windowMs) / 1000); 33 34 return { 35 success: false, 36 remaining: 0, 37 resetAt, 38 retryAfter: resetAt - Math.ceil(now / 1000), 39 }; 40 } 41 42 return { 43 success: true, 44 remaining: limit - count, 45 resetAt: Math.ceil((now + windowMs) / 1000), 46 }; 47}

API Routes#

Create API Key#

1// app/api/api-keys/route.ts 2import { auth } from '@clerk/nextjs/server'; 3import { NextRequest, NextResponse } from 'next/server'; 4import { createApiKey, listApiKeys } from '@/lib/api-keys'; 5import { z } from 'zod'; 6 7const createKeySchema = z.object({ 8 name: z.string().min(1).max(100), 9 scopes: z.array(z.string()).optional(), 10 expiresInDays: z.number().positive().optional(), 11}); 12 13export async function POST(req: NextRequest) { 14 const { userId } = await auth(); 15 if (!userId) { 16 return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); 17 } 18 19 const body = await req.json(); 20 const parsed = createKeySchema.safeParse(body); 21 22 if (!parsed.success) { 23 return NextResponse.json( 24 { error: 'Invalid request', details: parsed.error.issues }, 25 { status: 400 } 26 ); 27 } 28 29 const { name, scopes, expiresInDays } = parsed.data; 30 31 const expiresAt = expiresInDays 32 ? new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000) 33 : undefined; 34 35 const apiKey = await createApiKey(userId, name, { scopes, expiresAt }); 36 37 return NextResponse.json({ 38 id: apiKey.id, 39 name: apiKey.name, 40 key: apiKey.key, // Only returned once! 41 prefix: apiKey.keyPrefix, 42 scopes: apiKey.scopes, 43 expiresAt: apiKey.expiresAt, 44 createdAt: apiKey.createdAt, 45 }); 46} 47 48export async function GET() { 49 const { userId } = await auth(); 50 if (!userId) { 51 return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); 52 } 53 54 const keys = await listApiKeys(userId); 55 return NextResponse.json(keys); 56}

Delete API Key#

1// app/api/api-keys/[id]/route.ts 2import { auth } from '@clerk/nextjs/server'; 3import { NextRequest, NextResponse } from 'next/server'; 4import { revokeApiKey } from '@/lib/api-keys'; 5 6export async function DELETE( 7 req: NextRequest, 8 { params }: { params: { id: string } } 9) { 10 const { userId } = await auth(); 11 if (!userId) { 12 return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); 13 } 14 15 await revokeApiKey(params.id, userId); 16 17 return NextResponse.json({ success: true }); 18}

Protected API Endpoint#

1// app/api/v1/data/route.ts 2import { NextRequest, NextResponse } from 'next/server'; 3import { withApiKeyAuth } from '@/lib/api-auth'; 4 5export const GET = withApiKeyAuth( 6 async (req, ctx) => { 7 // Access authenticated context 8 console.log('API Key:', ctx.apiKey.name); 9 console.log('User:', ctx.user.email); 10 11 // Your API logic here 12 const data = await fetchData(ctx.user.id); 13 14 return NextResponse.json(data); 15 }, 16 { 17 requiredScopes: ['read:data'], 18 } 19); 20 21export const POST = withApiKeyAuth( 22 async (req, ctx) => { 23 const body = await req.json(); 24 25 // Your API logic here 26 const result = await createData(ctx.user.id, body); 27 28 return NextResponse.json(result); 29 }, 30 { 31 requiredScopes: ['write:data'], 32 } 33);

API Key Management UI#

1// components/ApiKeyManager.tsx 2'use client'; 3 4import { useState } from 'react'; 5import { Copy, Trash2, Key, Eye, EyeOff } from 'lucide-react'; 6import { formatDistanceToNow } from 'date-fns'; 7 8interface ApiKey { 9 id: string; 10 name: string; 11 keyPrefix: string; 12 scopes: string[]; 13 lastUsedAt: string | null; 14 expiresAt: string | null; 15 createdAt: string; 16} 17 18export function ApiKeyManager() { 19 const [keys, setKeys] = useState<ApiKey[]>([]); 20 const [newKey, setNewKey] = useState<string | null>(null); 21 const [creating, setCreating] = useState(false); 22 const [name, setName] = useState(''); 23 24 const createKey = async () => { 25 setCreating(true); 26 try { 27 const res = await fetch('/api/api-keys', { 28 method: 'POST', 29 headers: { 'Content-Type': 'application/json' }, 30 body: JSON.stringify({ name, scopes: ['read:data', 'write:data'] }), 31 }); 32 const data = await res.json(); 33 setNewKey(data.key); 34 setKeys([data, ...keys]); 35 setName(''); 36 } finally { 37 setCreating(false); 38 } 39 }; 40 41 const deleteKey = async (id: string) => { 42 await fetch(`/api/api-keys/${id}`, { method: 'DELETE' }); 43 setKeys(keys.filter((k) => k.id !== id)); 44 }; 45 46 const copyToClipboard = (text: string) => { 47 navigator.clipboard.writeText(text); 48 }; 49 50 return ( 51 <div className="space-y-6"> 52 <div className="flex gap-4"> 53 <input 54 type="text" 55 value={name} 56 onChange={(e) => setName(e.target.value)} 57 placeholder="API Key Name" 58 className="flex-1 px-3 py-2 border rounded-lg" 59 /> 60 <button 61 onClick={createKey} 62 disabled={!name || creating} 63 className="px-4 py-2 bg-brand-600 text-white rounded-lg disabled:opacity-50" 64 > 65 Create Key 66 </button> 67 </div> 68 69 {newKey && ( 70 <div className="p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg"> 71 <p className="font-medium text-green-800 dark:text-green-200 mb-2"> 72 Your new API key (save it now - you won't see it again): 73 </p> 74 <div className="flex items-center gap-2"> 75 <code className="flex-1 p-2 bg-white dark:bg-gray-800 rounded font-mono text-sm"> 76 {newKey} 77 </code> 78 <button 79 onClick={() => copyToClipboard(newKey)} 80 className="p-2 hover:bg-green-100 dark:hover:bg-green-800 rounded" 81 > 82 <Copy className="w-4 h-4" /> 83 </button> 84 </div> 85 <button 86 onClick={() => setNewKey(null)} 87 className="mt-2 text-sm text-green-600 dark:text-green-400" 88 > 89 I've saved it 90 </button> 91 </div> 92 )} 93 94 <div className="space-y-2"> 95 {keys.map((key) => ( 96 <div 97 key={key.id} 98 className="flex items-center justify-between p-4 bg-white dark:bg-gray-800 rounded-lg border" 99 > 100 <div className="flex items-center gap-3"> 101 <Key className="w-5 h-5 text-gray-400" /> 102 <div> 103 <p className="font-medium">{key.name}</p> 104 <p className="text-sm text-gray-500"> 105 {key.keyPrefix}•••••••• 106 </p> 107 </div> 108 </div> 109 <div className="flex items-center gap-4"> 110 <span className="text-sm text-gray-500"> 111 {key.lastUsedAt 112 ? `Used ${formatDistanceToNow(new Date(key.lastUsedAt), { addSuffix: true })}` 113 : 'Never used'} 114 </span> 115 <button 116 onClick={() => deleteKey(key.id)} 117 className="p-2 text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded" 118 > 119 <Trash2 className="w-4 h-4" /> 120 </button> 121 </div> 122 </div> 123 ))} 124 </div> 125 </div> 126 ); 127}

Usage Example#

1# Using the API key 2curl -X GET https://api.example.com/v1/data \ 3 -H "Authorization: Bearer bs_abc123..." 4 5# Or with X-API-Key header 6curl -X GET https://api.example.com/v1/data \ 7 -H "X-API-Key: bs_abc123..."