Claude Code Boilerplate
FeaturesPricingBlogDocs
Get started →

Product

  • Features
  • Pricing
  • Skills

Compare

  • vs ShipFast
  • vs MakerKit
  • vs supastarter

Resources

  • Docs
  • Blog
  • Discord

Legal

  • License
  • Privacy Policy
  • Terms of Service
Claude Code Boilerplate

© 2026 Claude Code Boilerplate. All rights reserved.

← All posts

API key management in Next.js -- issuing, hashing, and scoping keys with Drizzle ORM

June 12, 2026
nextjsdrizzle-ormauthsaas

Offering API keys is a common SaaS requirement: CLI tools, third-party integrations, and CI pipelines all need a stable credential that is not a session token. This post shows the full pattern -- schema, key generation, hashing, and route validation -- using Drizzle ORM and Node's built-in crypto module.

The schema

Never store a raw API key. Store a SHA-256 hash and show the plaintext once on creation. Add a prefix column so users can identify which key is which without needing the full secret.

// modules/apiKey/apiKey.schema.ts
import { pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
import { userTable } from '../user/user.schema';
 
export const apiKeyTable = pgTable('api_keys', {
  id: uuid('id').primaryKey().defaultRandom(),
  userId: uuid('user_id').notNull().references(() => userTable.id, { onDelete: 'cascade' }),
  name: text('name').notNull(),
  prefix: text('prefix').notNull(),
  keyHash: text('key_hash').notNull().unique(),
  createdAt: timestamp('created_at').notNull().defaultNow(),
  lastUsedAt: timestamp('last_used_at'),
  expiresAt: timestamp('expires_at'),
});

prefix stores the first 16 characters of the generated key (e.g. sk_live_ab12cd34) so users can identify a key in the UI list without you re-exposing the secret. keyHash is what gets queried on every incoming request.

Generating and hashing keys

Use crypto.randomBytes -- no third-party dependency needed.

// modules/apiKey/apiKey.service.ts
import { createHash, randomBytes } from 'crypto';
 
function generateApiKey(): { raw: string; prefix: string; hash: string } {
  const secret = randomBytes(32).toString('hex');  // 64 hex chars
  const raw = `sk_live_${secret}`;
  const prefix = raw.slice(0, 16);                    // shown in UI
  const hash = createHash('sha256').update(raw).digest('hex');
  return { raw, prefix, hash };
}

raw is returned to the caller once and never stored. If the user loses it, they issue a new key.

Issuing a key

// modules/apiKey/apiKey.service.ts (continued)
import { apiKeyRepo } from './apiKey.repo';
import { HttpError } from '@/lib/errors/HttpError';
 
export const apiKeyService = {
  async createKey(userId: string, name: string) {
    const { raw, prefix, hash } = generateApiKey();
    const key = await apiKeyRepo.insert({ userId, name, prefix, keyHash: hash });
    return { ...key, raw };  // raw is returned here and ONLY here
  },
 
  async listKeys(userId: string) {
    return apiKeyRepo.findByUser(userId);  // never include keyHash in the select
  },
 
  async deleteKey(userId: string, keyId: string) {
    const key = await apiKeyRepo.findById(keyId);
    if (!key || key.userId !== userId) throw new HttpError(404, 'Key not found');
    await apiKeyRepo.delete(keyId);
  },
};

The POST /api/api-keys route calls apiKeyService.createKey, pulls raw from the result, and returns it once in the response body. The client must copy it -- it will not be shown again.

Validating a key in a route

Hash what was sent and look it up by hash. Never do a plaintext comparison.

// lib/apiKeyAuth.ts
import { createHash } from 'crypto';
import { db } from '@/db/drizzle';
import { apiKeyTable } from '@/modules/apiKey/apiKey.schema';
import { eq } from 'drizzle-orm';
import { HttpError } from './errors/HttpError';
 
export async function getApiKeyOwner(req: Request): Promise<string> {
  const raw =
    req.headers.get('x-api-key') ??
    req.headers.get('authorization')?.replace('Bearer ', '');
  if (!raw) throw new HttpError(401, 'Missing API key');
 
  const hash = createHash('sha256').update(raw).digest('hex');
  const [key] = await db
    .select({ userId: apiKeyTable.userId, expiresAt: apiKeyTable.expiresAt })
    .from(apiKeyTable)
    .where(eq(apiKeyTable.keyHash, hash))
    .limit(1);
 
  if (!key) throw new HttpError(401, 'Invalid API key');
  if (key.expiresAt && key.expiresAt < new Date()) {
    throw new HttpError(401, 'API key expired');
  }
 
  // fire-and-forget -- do not await so it does not block the response
  db.update(apiKeyTable)
    .set({ lastUsedAt: new Date() })
    .where(eq(apiKeyTable.keyHash, hash))
    .execute();
 
  return key.userId;
}

Use getApiKeyOwner in any route that needs programmatic access:

// app/api/data/route.ts
import { getApiKeyOwner } from '@/lib/apiKeyAuth';
import { handleError } from '@/lib/errors';
 
export async function GET(req: Request) {
  try {
    const userId = await getApiKeyOwner(req);
    // ... fetch and return user-scoped data
    return Response.json({ ok: true });
  } catch (error) {
    return handleError(error);
  }
}

Key rotation without downtime

When a user wants to rotate a key, issue the new one before revoking the old one:

  1. POST /api/api-keys -- creates a new key, returns raw
  2. User deploys the new key to their environment
  3. DELETE /api/api-keys/:id -- revokes the old key

Both keys work simultaneously during the cutover window because each row is independent in the DB.

What to ship in the UI

  • A "Create key" form with a name field so users remember what each key is for
  • A key list showing prefix, name, createdAt, lastUsedAt
  • A one-time reveal modal after creation -- show raw, a copy button, and a warning that it will not be shown again
  • A "Revoke" button per row that calls DELETE /api/api-keys/:id

Takeaway

The pattern is randomBytes for entropy, SHA-256 for storage, keyHash lookup for validation, and prefix for UX. The DB never holds a secret that can be reversed -- only a hash that can be matched. Drop getApiKeyOwner into any route that needs programmatic access and you have API key auth that holds up in production without any external dependency.