Why Your SaaS Needs API Keys
Not every user wants OAuth. Developers building integrations need API keys:
programmatic access, CI/CD pipelines, scripts, third-party apps.
Here's how to implement a secure, production-ready API key system in Next.js.
The Data Model
model ApiKey {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id])
name String // 'Production', 'CI/CD', etc.
keyHash String @unique // bcrypt hash of the key
keyPrefix String // First 8 chars for display: 'sk_live_abc12345'
lastUsedAt DateTime?
createdAt DateTime @default(now())
expiresAt DateTime?
revokedAt DateTime?
@@index([userId])
}
Generating a Secure Key
import crypto from 'crypto'
import bcrypt from 'bcryptjs'
function generateApiKey(): { raw: string; hash: string; prefix: string } {
// 32 random bytes = 256-bit key
const rawKey = `sk_live_${crypto.randomBytes(32).toString('hex')}`
const prefix = rawKey.slice(0, 16) // 'sk_live_' + 8 hex chars
const hash = bcrypt.hashSync(rawKey, 12)
return { raw: rawKey, hash, prefix }
}
Critical: store only the hash. The raw key is shown to the user once and never saved.
Create Key API Route
// app/api/keys/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { db } from '@/lib/db'
import { generateApiKey } from '@/lib/api-keys'
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions)
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { name } = await req.json()
if (!name?.trim()) {
return NextResponse.json({ error: 'Key name required' }, { status: 400 })
}
// Enforce limit: max 5 API keys per user
const count = await db.apiKey.count({
where: { userId: session.user.id, revokedAt: null }
})
if (count >= 5) {
return NextResponse.json({ error: 'API key limit reached' }, { status: 400 })
}
const { raw, hash, prefix } = generateApiKey()
await db.apiKey.create({
data: {
userId: session.user.id,
name,
keyHash: hash,
keyPrefix: prefix
}
})
// Return raw key ONCE - never stored
return NextResponse.json({ key: raw })
}
export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions)
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const keys = await db.apiKey.findMany({
where: { userId: session.user.id, revokedAt: null },
select: { id: true, name: true, keyPrefix: true, lastUsedAt: true, createdAt: true }
})
return NextResponse.json({ keys })
}
Validating Keys on API Requests
// lib/validate-api-key.ts
import { db } from '@/lib/db'
import bcrypt from 'bcryptjs'
export async function validateApiKey(rawKey: string) {
if (!rawKey?.startsWith('sk_live_')) return null
const prefix = rawKey.slice(0, 16)
// Find candidates by prefix (avoids full-table scan)
const candidates = await db.apiKey.findMany({
where: { keyPrefix: prefix, revokedAt: null },
include: { user: true }
})
for (const candidate of candidates) {
const matches = await bcrypt.compare(rawKey, candidate.keyHash)
if (matches) {
// Update last used timestamp (fire and forget)
db.apiKey.update({
where: { id: candidate.id },
data: { lastUsedAt: new Date() }
}).catch(() => {})
return candidate.user
}
}
return null
}
Protected API Route Using API Keys
// app/api/v1/data/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { validateApiKey } from '@/lib/validate-api-key'
export async function GET(req: NextRequest) {
const authHeader = req.headers.get('authorization')
const rawKey = authHeader?.replace('Bearer ', '')
if (!rawKey) {
return NextResponse.json(
{ error: 'Missing Authorization header' },
{ status: 401 }
)
}
const user = await validateApiKey(rawKey)
if (!user) {
return NextResponse.json(
{ error: 'Invalid or revoked API key' },
{ status: 401 }
)
}
// User is authenticated via API key
return NextResponse.json({ data: 'Your data here', userId: user.id })
}
Revoking Keys
// app/api/keys/[id]/route.ts
export async function DELETE(req: NextRequest, { params }: { params: { id: string } }) {
const session = await getServerSession(authOptions)
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
// Soft delete - set revokedAt
const key = await db.apiKey.findFirst({
where: { id: params.id, userId: session.user.id }
})
if (!key) return NextResponse.json({ error: 'Not found' }, { status: 404 })
await db.apiKey.update({
where: { id: params.id },
data: { revokedAt: new Date() }
})
return NextResponse.json({ success: true })
}
Security Rules
- Never log raw keys - they're credentials
- Rate limit key validation - 100 req/min per IP
- Prefix lookup before hash compare - prevents timing attacks at scale
- Soft delete only - keep revoked keys for audit logs
- Show the key once - copy-to-clipboard UI, then never again
Don't Build This from Scratch
The AI SaaS Starter Kit ships with API key management pre-built:
generation, hashing, validation middleware, dashboard UI, and revocation.
$99 one-time at whoffagents.com
Top comments (0)