APIS

API Key Management in Next.js

How to generate, hash, store, and revoke API keys in a Next.js app using SHA-256 hashing, server actions, and cross-project guards.

If your app exposes an API, you need a way for users to authenticate their requests without sending a password on every call. API keys are the standard approach: generate a key, give it to the user once, and store only the hash. Here is how to build a complete API key lifecycle in a Next.js app, from generation through revocation, with proper security at each step.

The Data Model

The Prisma schema for an API key needs a few specific fields beyond the obvious token column.

model ApiKey {
  id          String    @id @default(cuid())
  tokenHash   String    @unique @map("token_hash")
  prefix      String    @map("prefix")
  name        String?
  projectId   String    @map("project_id")
  project     Project   @relation(fields: [projectId], references: [id])
  lastUsedAt  DateTime? @map("last_used_at")
  createdBy   String    @map("created_by")
  revokedAt   DateTime? @map("revoked_at")
  createdAt   DateTime  @default(now()) @map("created_at")
  updatedAt   DateTime  @updatedAt @map("updated_at")

  @@map("api_keys")
}

The tokenHash field stores a SHA-256 hash of the full key. The prefix field stores the first few characters of the key (like the sq_live_ prefix) so users can identify which key is which in a list. You never store the full key.

The lastUsedAt timestamp gets updated on every authenticated request. This helps users identify stale keys and rotate them. The revokedAt field soft-deletes the key without losing audit history.

The @unique index on tokenHash is what makes lookups fast. When a request comes in with an API key, you hash it and search for the matching row. The index makes this a constant-time operation regardless of how many keys exist.

Generating API Keys

API key generation has two requirements: the key must have enough entropy to be unguessable, and it must have a recognizable prefix so users can tell keys apart at a glance.

import { randomBytes } from 'crypto'

export function generateApiKey(): string {
  const bytes = randomBytes(16) // 128 bits of entropy
  const token = bytes.toString('hex')
  return `sq_live_${token}`
}

The sq_live_ prefix serves two purposes. First, it makes the key identifiable in logs, configuration files, and environment variables. A key that starts with sq_live_ is clearly a production API key, not a database password or some other credential. Second, it helps with validation: if a key doesn't start with the expected prefix, reject it immediately without computing a hash.

128 bits of entropy (16 random bytes) is more than enough. For comparison, a UUID is 122 bits. The hex encoding doubles the length to 32 characters, giving you a total key of sq_live_ + 32 hex characters = 41 characters.

Hashing API Keys

When a user creates a key, you show them the full key exactly once, then store only the hash. SHA-256 is the right choice here. It's fast, widely available, and the output is always 64 hex characters.

import { createHash } from 'crypto'

export function hashApiKey(token: string): string {
  return createHash('sha256').update(token).digest('hex')
}

export function extractPrefix(token: string): string {
  const parts = token.split('_')
  return `${parts[0]}_${parts[1]}_`  // "sq_live_"
}

The key insight: SHA-256 is a one-way function. Given the hash, an attacker cannot recover the original key. The hash is all you store. When a request comes in with a key in the Authorization header, you hash it and look up the matching row.

Using crypto.createHash from Node's built-in module is the right call here. Don't reach for bcrypt or argon2 for API key hashing. Those algorithms are designed for password hashing, where you want the computation to be slow. API key lookups happen on every request. SHA-256 is fast enough that it adds negligible latency while still being one-way.

The Create Action

The create server action generates a key, hashes it, stores the hash and prefix, and returns the full key to the client. This is the only time the full key is available.

'use server'

import { generateApiKey, hashApiKey, extractPrefix } from '@/lib/crypto/apiKey'
import { prisma } from '@/lib/prisma'

export async function createApiKey(input: {
  name?: string
  projectId: string
  createdBy: string
}) {
  const token = generateApiKey()
  const tokenHash = hashApiKey(token)
  const prefix = extractPrefix(token)

  await prisma.apiKey.create({
    data: {
      tokenHash,
      prefix,
      name: input.name,
      projectId: input.projectId,
      createdBy: input.createdBy,
    },
  })

  // The full key is returned exactly once.
  // After this response, it's gone forever.
  return { token, prefix }
}

Show the key in the UI immediately after creation with a clear warning: copy this now, you won't see it again. After the response, the full key is not recoverable. If the user loses it, they need to revoke the old key and create a new one.

Listing and Revoking Keys

The list action returns only non-revoked keys for the current project, with enough information for the user to identify each key.

'use server'

export async function listApiKeys(projectId: string) {
  return prisma.apiKey.findMany({
    where: {
      projectId,
      revokedAt: null,
    },
    select: {
      id: true,
      prefix: true,
      name: true,
      lastUsedAt: true,
      createdAt: true,
    },
    orderBy: { createdAt: 'desc' },
  })
}

The list returns the prefix (like sq_live_) but never the full key or the hash. The lastUsedAt field helps users find stale keys. A key that hasn't been used in 90 days is a candidate for rotation.

Revocation sets the revokedAt timestamp and checks that the key belongs to the current project. This cross-project guard prevents a user from revoking another project's key.

'use server'

export async function revokeApiKey(keyId: string, projectId: string) {
  const key = await prisma.apiKey.findFirst({
    where: { id: keyId, projectId },
  })

  if (!key) {
    throw new Error('API key not found or does not belong to this project')
  }

  if (key.revokedAt) {
    throw new Error('API key already revoked')
  }

  await prisma.apiKey.update({
    where: { id: keyId },
    data: { revokedAt: new Date() },
  })
}

The cross-project check is essential. Without it, any authenticated user could revoke any API key by guessing or enumerating IDs. The projectId check ensures that a user in Project A cannot touch keys in Project B.

Authenticating Requests

When a request comes in with a Bearer token in the Authorization header, you hash it and look up the corresponding row:

export async function authenticateApiKey(request: Request) {
  const authHeader = request.headers.get('Authorization')
  if (!authHeader?.startsWith('Bearer ')) return null

  const token = authHeader.slice(7)
  const tokenHash = hashApiKey(token)

  const apiKey = await prisma.apiKey.findUnique({
    where: { tokenHash },
    include: { project: true },
  })

  if (!apiKey || apiKey.revokedAt) return null

  // Update lastUsedAt asynchronously
  prisma.apiKey.update({
    where: { id: apiKey.id },
    data: { lastUsedAt: new Date() },
  }).catch(() => {}) // Don't block the request on this

  return { project: apiKey.project, apiKey }
}

The lastUsedAt update is intentionally fire-and-forget. It's analytics metadata, not critical for auth. Don't let a slow database write block the user's request.

Testing the Crypto Helpers

The crypto functions are deterministic, which makes them straightforward to test:

import { generateApiKey, hashApiKey, extractPrefix } from '@/lib/crypto/apiKey'

test('generateApiKey produces a key with the correct prefix', () => {
  const key = generateApiKey()
  expect(key.startsWith('sq_live_')).toBe(true)
  expect(key.length).toBe(41) // "sq_live_" (8) + 32 hex chars
})

test('generateApiKey produces unique keys', () => {
  const keys = new Set(Array.from({ length: 100 }, () => generateApiKey()))
  expect(keys.size).toBe(100)
})

test('hashApiKey produces a consistent SHA-256 hash', () => {
  const key = 'sq_live_abcdef1234567890abcdef1234567890'
  const hash1 = hashApiKey(key)
  const hash2 = hashApiKey(key)
  expect(hash1).toBe(hash2)
  expect(hash1).toHaveLength(64) // SHA-256 hex output
})

test('extractPrefix returns the prefix portion', () => {
  const key = 'sq_live_abcdef1234567890abcdef1234567890'
  expect(extractPrefix(key)).toBe('sq_live_')
})

For the server actions, integration tests verify the cross-project guard:

test('cannot revoke a key from another project', async () => {
  const keyInProjectA = await createApiKey({
    projectId: 'project-a',
    createdBy: 'user-1',
  })

  await expect(
    revokeApiKey(keyInProjectA.id, 'project-b')
  ).rejects.toThrow('not found or does not belong')
})

Conclusion

API key management comes down to a few principles: generate keys with sufficient entropy, hash them before storage, show the full key once, and guard every mutation with a project membership check. The prefix field makes keys identifiable without exposing the full value. SHA-256 hashing is fast and one-way, which is exactly what you need for keys that are looked up on every request. And the cross-project check on revocation prevents a whole class of authorization bugs that are easy to overlook.

← Older
API Rate Limiting and Throttling
Newer →
API Idempotency

Newsletter

A weekly newsletter on React, Next.js, AI-assisted development, and engineering. No spam, unsubscribe any time.