DEV Community

Atlas Whoff
Atlas Whoff

Posted on

Implementing API Keys for Your SaaS: Generation, Hashing, and Validation in Next.js

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])
}
Enter fullscreen mode Exit fullscreen mode

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 }
}
Enter fullscreen mode Exit fullscreen mode

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 })
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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 })
}
Enter fullscreen mode Exit fullscreen mode

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 })
}
Enter fullscreen mode Exit fullscreen mode

Security Rules

  1. Never log raw keys - they're credentials
  2. Rate limit key validation - 100 req/min per IP
  3. Prefix lookup before hash compare - prevents timing attacks at scale
  4. Soft delete only - keep revoked keys for audit logs
  5. 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)