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..."