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.
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.
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.
// 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.
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);
}
}
When a user wants to rotate a key, issue the new one before revoking the old one:
POST /api/api-keys -- creates a new key, returns rawDELETE /api/api-keys/:id -- revokes the old keyBoth keys work simultaneously during the cutover window because each row is independent in the DB.
name field so users remember what each key is forprefix, name, createdAt, lastUsedAtraw, a copy button, and a warning that it will not be shown againDELETE /api/api-keys/:idThe 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.