Add production-grade security to your Hono API with JWT authentication, API keys, rate limiting, role-based access control, and audit logging on Cloudflare Workers.
Introduction
In Part 1, we built a REST API with Hono on Cloudflare Workers. Now let's add production-grade security: JWT authentication, API key management, rate limiting, role-based access control (RBAC), and audit logging. These patterns protect your API from abuse while providing flexibility for different client types.
You are here
Secure Your API
Previous
Build a REST API
Next
Coming soon
All parts in this series
Security Features Overview
| Feature | Purpose |
|---|---|
| JWT Authentication | Short-lived access tokens + refresh token rotation |
| API Keys | Server-to-server integration with scopes |
| Rate Limiting | Protect against abuse (60 req/min default) |
| RBAC | Admin, Editor, Viewer roles with permissions |
| Security Headers | HSTS, CSP, XSS protection |
| Audit Logging | Track all actions with waitUntil |
Prerequisites
Project Configuration
Before adding security features, ensure your project has the correct configuration.
package.json
1{2 "name": "hono-cloudflare-workers-starter",3 "version": "2.0.0",4 "type": "module",5 "scripts": {6 "dev": "wrangler dev",7 "deploy": "wrangler deploy",8 "db:create": "wrangler d1 create my-database",9 "db:migrate:local": "npm run db:migrate:local:posts && npm run db:migrate:local:auth",10 "db:migrate:local:posts": "wrangler d1 execute my-database --local --file=./migrations/0001_create_posts.sql",11 "db:migrate:local:auth": "wrangler d1 execute my-database --local --file=./migrations/0002_create_auth_tables.sql",12 "db:migrate:remote": "npm run db:migrate:remote:posts && npm run db:migrate:remote:auth",13 "db:migrate:remote:posts": "wrangler d1 execute my-database --remote --file=./migrations/0001_create_posts.sql",14 "db:migrate:remote:auth": "wrangler d1 execute my-database --remote --file=./migrations/0002_create_auth_tables.sql",15 "secret:jwt": "wrangler secret put JWT_SECRET",16 "test": "vitest run",17 "test:watch": "vitest",18 "typecheck": "tsc --noEmit",19 "logs": "wrangler tail"20 },21 "dependencies": {22 "@hono/zod-validator": "^0.4.2",23 "hono": "^4.7.0",24 "zod": "^3.24.1"25 },26 "devDependencies": {27 "@cloudflare/vitest-pool-workers": "^0.8.0",28 "@cloudflare/workers-types": "^4.20250109.0",29 "typescript": "^5.7.3",30 "vitest": "^3.0.2",31 "wrangler": "^4.58.0"32 }33}tsconfig.json
1{2 "compilerOptions": {3 "target": "ESNext",4 "module": "ESNext",5 "moduleResolution": "Bundler",6 "strict": true,7 "skipLibCheck": true,8 "lib": ["ESNext"],9 "types": [10 "@cloudflare/workers-types",11 "@cloudflare/vitest-pool-workers",12 "vitest/globals"13 ],14 "jsx": "react-jsx",15 "jsxImportSource": "hono/jsx",16 "noEmit": true,17 "isolatedModules": true,18 "allowSyntheticDefaultImports": true,19 "forceConsistentCasingInFileNames": true20 },21 "include": ["src/**/*", "tests/**/*"],22 "exclude": ["node_modules"]23}Step 1: Database Schema for Authentication
We need tables for users, refresh tokens, API keys, roles, permissions, and audit logs.
1-- migrations/0002_create_auth_tables.sql23-- Users table4CREATE TABLE IF NOT EXISTS users (5 id TEXT PRIMARY KEY,6 email TEXT UNIQUE NOT NULL,7 password_hash TEXT NOT NULL,8 role TEXT NOT NULL DEFAULT 'user',9 is_active INTEGER DEFAULT 1,10 created_at TEXT DEFAULT CURRENT_TIMESTAMP,11 updated_at TEXT DEFAULT CURRENT_TIMESTAMP12);1314-- Refresh tokens for JWT rotation15CREATE TABLE IF NOT EXISTS refresh_tokens (16 id TEXT PRIMARY KEY,17 user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,18 token_hash TEXT UNIQUE NOT NULL,19 family_id TEXT NOT NULL,20 expires_at TEXT NOT NULL,21 created_at TEXT DEFAULT CURRENT_TIMESTAMP,22 revoked_at TEXT DEFAULT NULL23);2425-- API keys for server-to-server auth26CREATE TABLE IF NOT EXISTS api_keys (27 id TEXT PRIMARY KEY,28 user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,29 key_prefix TEXT NOT NULL,30 key_hash TEXT UNIQUE NOT NULL,31 name TEXT NOT NULL,32 scopes TEXT NOT NULL DEFAULT '[]',33 rate_limit INTEGER DEFAULT 1000,34 last_used_at TEXT DEFAULT NULL,35 expires_at TEXT DEFAULT NULL,36 created_at TEXT DEFAULT CURRENT_TIMESTAMP,37 revoked_at TEXT DEFAULT NULL38);3940-- Roles for RBAC41CREATE TABLE IF NOT EXISTS roles (42 id TEXT PRIMARY KEY,43 name TEXT UNIQUE NOT NULL,44 description TEXT,45 created_at TEXT DEFAULT CURRENT_TIMESTAMP46);4748-- Permissions for RBAC49CREATE TABLE IF NOT EXISTS permissions (50 id TEXT PRIMARY KEY,51 name TEXT UNIQUE NOT NULL,52 resource TEXT NOT NULL,53 action TEXT NOT NULL,54 created_at TEXT DEFAULT CURRENT_TIMESTAMP55);5657-- Role-permission junction58CREATE TABLE IF NOT EXISTS role_permissions (59 role_id TEXT NOT NULL REFERENCES roles(id) ON DELETE CASCADE,60 permission_id TEXT NOT NULL REFERENCES permissions(id) ON DELETE CASCADE,61 PRIMARY KEY (role_id, permission_id)62);6364-- Audit logs65CREATE TABLE IF NOT EXISTS audit_logs (66 id TEXT PRIMARY KEY,67 user_id TEXT REFERENCES users(id) ON DELETE SET NULL,68 action TEXT NOT NULL,69 resource TEXT NOT NULL,70 resource_id TEXT,71 ip_address TEXT,72 user_agent TEXT,73 metadata TEXT DEFAULT '{}',74 created_at TEXT DEFAULT CURRENT_TIMESTAMP75);7677-- Create indexes78CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);79CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);80CREATE INDEX IF NOT EXISTS idx_api_keys_prefix ON api_keys(key_prefix);81CREATE INDEX IF NOT EXISTS idx_audit_logs_user ON audit_logs(user_id);82CREATE INDEX IF NOT EXISTS idx_audit_logs_created ON audit_logs(created_at DESC);8384-- Seed default roles and permissions85INSERT OR IGNORE INTO roles (id, name, description) VALUES86 ('role_admin', 'admin', 'Full administrative access'),87 ('role_editor', 'editor', 'Can create and edit content'),88 ('role_viewer', 'viewer', 'Read-only access');8990INSERT OR IGNORE INTO permissions (id, name, resource, action) VALUES91 ('perm_posts_create', 'posts:create', 'posts', 'create'),92 ('perm_posts_read', 'posts:read', 'posts', 'read'),93 ('perm_posts_update', 'posts:update', 'posts', 'update'),94 ('perm_posts_delete', 'posts:delete', 'posts', 'delete');Step 2: Update Type Definitions
Add new context variables for authenticated users.
1// src/types/bindings.ts2export type Bindings = {3 DB: D1Database;4 JWT_SECRET: string;5 RATE_LIMITER?: {6 limit: (options: { key: string }) => Promise<{ success: boolean }>;7 };8 RATE_LIMIT_KV?: KVNamespace;9};1011export type Variables = {12 requestId: string;13 userId?: string;14 userEmail?: string;15 userRole?: string;16 apiKeyId?: string;17 apiKeyScopes?: string[];18};1920export type AppEnv = {21 Bindings: Bindings;22 Variables: Variables;23};Step 3: JWT Authentication Middleware
Create JWT middleware with access/refresh token support and token rotation.
1// src/middleware/jwt-auth.ts2import { createMiddleware } from 'hono/factory';3import { sign, verify } from 'hono/jwt';4import { HTTPException } from 'hono/http-exception';5import type { AppEnv } from '../types/bindings';67export const JWT_CONFIG = {8 accessTokenExpiry: 15 * 60, // 15 minutes9 refreshTokenExpiry: 7 * 24 * 60 * 60, // 7 days10 algorithm: 'HS256' as const,11};1213export interface JWTPayload {14 sub: string;15 email: string;16 role: string;17 type: 'access' | 'refresh';18 iat: number;19 exp: number;20 [key: string]: unknown;21}2223export async function generateAccessToken(24 user: { id: string; email: string; role: string },25 secret: string26): Promise<string> {27 const now = Math.floor(Date.now() / 1000);28 return sign({29 sub: user.id,30 email: user.email,31 role: user.role,32 type: 'access',33 iat: now,34 exp: now + JWT_CONFIG.accessTokenExpiry,35 }, secret, JWT_CONFIG.algorithm);36}3738export async function hashToken(token: string): Promise<string> {39 const encoder = new TextEncoder();40 const data = encoder.encode(token);41 const hashBuffer = await crypto.subtle.digest('SHA-256', data);42 const hashArray = Array.from(new Uint8Array(hashBuffer));43 return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');44}4546export function jwtAuth() {47 return createMiddleware<AppEnv>(async (c, next) => {48 const authHeader = c.req.header('Authorization');4950 if (!authHeader?.startsWith('Bearer ')) {51 throw new HTTPException(401, {52 message: 'Missing or invalid Authorization header',53 });54 }5556 const token = authHeader.slice(7);57 const secret = c.env.JWT_SECRET;5859 if (!secret) {60 throw new HTTPException(500, { message: 'Server configuration error' });61 }6263 try {64 const payload = await verify(token, secret, JWT_CONFIG.algorithm) as unknown as JWTPayload;6566 if (payload.type !== 'access') {67 throw new HTTPException(401, { message: 'Invalid token type' });68 }6970 c.set('userId', payload.sub);71 c.set('userEmail', payload.email);72 c.set('userRole', payload.role);7374 await next();75 } catch (error) {76 if (error instanceof HTTPException) throw error;77 throw new HTTPException(401, { message: 'Invalid or expired token' });78 }79 });80}Step 4: API Key Authentication
Create Stripe-style API keys with scopes for server-to-server integration.
1// src/middleware/api-key-auth.ts2import { createMiddleware } from 'hono/factory';3import { HTTPException } from 'hono/http-exception';4import type { AppEnv } from '../types/bindings';56export const API_KEY_PREFIX = {7 secretLive: 'sk_live_',8 secretTest: 'sk_test_',9};1011export async function generateApiKey(12 type: 'secret' = 'secret',13 isLive: boolean = true14): Promise<{ key: string; prefix: string }> {15 const prefix = isLive ? API_KEY_PREFIX.secretLive : API_KEY_PREFIX.secretTest;16 const randomBytes = new Uint8Array(32);17 crypto.getRandomValues(randomBytes);18 const randomPart = btoa(String.fromCharCode(...randomBytes))19 .replace(/\+/g, '-')20 .replace(/\//g, '_')21 .replace(/=/g, '');22 return { key: `${prefix}${randomPart}`, prefix };23}2425export async function hashApiKey(key: string): Promise<string> {26 const encoder = new TextEncoder();27 const data = encoder.encode(key);28 const hashBuffer = await crypto.subtle.digest('SHA-256', data);29 const hashArray = Array.from(new Uint8Array(hashBuffer));30 return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');31}3233export function apiKeyAuth(options?: { scopes?: string[] }) {34 return createMiddleware<AppEnv>(async (c, next) => {35 const apiKey = c.req.header('X-API-Key');3637 if (!apiKey) {38 throw new HTTPException(401, { message: 'Missing X-API-Key header' });39 }4041 const keyHash = await hashApiKey(apiKey);42 const keyRecord = await c.env.DB.prepare(`43 SELECT ak.*, u.id as user_id, u.email, u.role, u.is_active44 FROM api_keys ak45 JOIN users u ON ak.user_id = u.id46 WHERE ak.key_hash = ?47 AND ak.revoked_at IS NULL48 AND (ak.expires_at IS NULL OR ak.expires_at > datetime('now'))49 `).bind(keyHash).first();5051 if (!keyRecord) {52 throw new HTTPException(401, { message: 'Invalid or expired API key' });53 }5455 if (!keyRecord.is_active) {56 throw new HTTPException(403, { message: 'User account is disabled' });57 }5859 // Check scopes60 if (options?.scopes?.length) {61 const keyScopes: string[] = JSON.parse(keyRecord.scopes as string || '[]');62 const hasAllScopes = options.scopes.every(s => keyScopes.includes(s));63 if (!hasAllScopes) {64 throw new HTTPException(403, { message: 'Insufficient API key permissions' });65 }66 }6768 // Update last used (non-blocking)69 c.executionCtx.waitUntil(70 c.env.DB.prepare("UPDATE api_keys SET last_used_at = datetime('now') WHERE id = ?")71 .bind(keyRecord.id)72 .run()73 );7475 c.set('userId', keyRecord.user_id as string);76 c.set('userEmail', keyRecord.email as string);77 c.set('userRole', keyRecord.role as string);78 c.set('apiKeyId', keyRecord.id as string);79 c.set('apiKeyScopes', JSON.parse(keyRecord.scopes as string || '[]'));8081 await next();82 });83}Step 5: Rate Limiting
Protect your API from abuse with configurable rate limits.
1// src/middleware/rate-limiter.ts2import { createMiddleware } from 'hono/factory';3import { HTTPException } from 'hono/http-exception';4import type { AppEnv } from '../types/bindings';56export const RATE_LIMIT_CONFIG = {7 anonymous: { limit: 60, period: 60 },8 authenticated: { limit: 1000, period: 60 },9 auth: { limit: 5, period: 60 }, // Strict for login attempts10};1112export function rateLimiter(options?: { limit?: number; period?: 10 | 60 }) {13 return createMiddleware<AppEnv>(async (c, next) => {14 const clientId = c.get('userId') || c.req.header('CF-Connecting-IP') || 'anonymous';15 const isAuthenticated = !!c.get('userId');1617 const config = isAuthenticated18 ? RATE_LIMIT_CONFIG.authenticated19 : RATE_LIMIT_CONFIG.anonymous;2021 const limit = options?.limit ?? config.limit;22 const rateLimiter = c.env.RATE_LIMITER;2324 if (!rateLimiter) {25 // Rate limiting not configured - allow request26 await next();27 return;28 }2930 try {31 const key = `${clientId}:${c.req.path}`;32 const { success } = await rateLimiter.limit({ key });3334 if (!success) {35 throw new HTTPException(429, {36 message: 'Too many requests. Please try again later.',37 });38 }3940 c.header('X-RateLimit-Limit', limit.toString());41 await next();42 } catch (error) {43 if (error instanceof HTTPException) throw error;44 await next();45 }46 });47}4849export function strictRateLimiter() {50 return rateLimiter({51 limit: RATE_LIMIT_CONFIG.auth.limit,52 period: RATE_LIMIT_CONFIG.auth.period as 10 | 60,53 });54}Step 6: Role-Based Access Control (RBAC)
Create middleware for role and permission checks.
1// src/middleware/rbac.ts2import { createMiddleware } from 'hono/factory';3import { HTTPException } from 'hono/http-exception';4import type { AppEnv } from '../types/bindings';56export const ROLE_HIERARCHY: Record<string, string[]> = {7 admin: ['editor', 'viewer', 'user'],8 editor: ['viewer', 'user'],9 viewer: ['user'],10 user: [],11};1213export function roleHasPermission(userRole: string, requiredRole: string): boolean {14 if (userRole === requiredRole) return true;15 const inherited = ROLE_HIERARCHY[userRole] || [];16 return inherited.includes(requiredRole);17}1819export function requireRole(role: string | string[]) {20 const roles = Array.isArray(role) ? role : [role];2122 return createMiddleware<AppEnv>(async (c, next) => {23 const userRole = c.get('userRole');2425 if (!userRole) {26 throw new HTTPException(401, { message: 'Authentication required' });27 }2829 const hasRole = roles.some(r => roleHasPermission(userRole, r));3031 if (!hasRole) {32 throw new HTTPException(403, {33 message: `Access denied. Required role: ${roles.join(' or ')}`,34 });35 }3637 await next();38 });39}4041export function requirePermission(permission: string | string[]) {42 const permissions = Array.isArray(permission) ? permission : [permission];4344 return createMiddleware<AppEnv>(async (c, next) => {45 const userId = c.get('userId');46 const userRole = c.get('userRole');4748 if (!userId || !userRole) {49 throw new HTTPException(401, { message: 'Authentication required' });50 }5152 // Check API key scopes first53 const apiKeyScopes = c.get('apiKeyScopes');54 if (apiKeyScopes?.every(p => permissions.includes(p))) {55 await next();56 return;57 }5859 // Query user permissions from database60 const { results } = await c.env.DB.prepare(`61 SELECT DISTINCT p.name62 FROM permissions p63 JOIN role_permissions rp ON p.id = rp.permission_id64 JOIN roles r ON rp.role_id = r.id65 WHERE r.name = ?66 `).bind(userRole).all();6768 const userPermissions = (results || []).map(r => r.name as string);69 const hasPermission = permissions.every(p => userPermissions.includes(p));7071 if (!hasPermission) {72 throw new HTTPException(403, {73 message: `Access denied. Required permission: ${permissions.join(', ')}`,74 });75 }7677 await next();78 });79}8081export const adminOnly = () => requireRole('admin');82export const editorOrAbove = () => requireRole(['admin', 'editor']);Step 7: Audit Logging
Track all actions with non-blocking writes using waitUntil.
1// src/middleware/audit-logger.ts2import { createMiddleware } from 'hono/factory';3import type { Context } from 'hono';4import type { AppEnv } from '../types/bindings';56const SENSITIVE_FIELDS = ['password', 'token', 'secret', 'authorization'];78function redactSensitiveData(data: Record<string, unknown>): Record<string, unknown> {9 const redacted: Record<string, unknown> = {};10 for (const [key, value] of Object.entries(data)) {11 if (SENSITIVE_FIELDS.some(f => key.toLowerCase().includes(f))) {12 redacted[key] = '[REDACTED]';13 } else if (typeof value === 'object' && value !== null) {14 redacted[key] = redactSensitiveData(value as Record<string, unknown>);15 } else {16 redacted[key] = value;17 }18 }19 return redacted;20}2122export async function logAudit(23 c: Context<AppEnv>,24 entry: {25 userId: string | null;26 action: string;27 resource: string;28 resourceId?: string;29 ipAddress?: string;30 userAgent?: string;31 metadata?: Record<string, unknown>;32 }33): Promise<void> {34 const db = c.env.DB;35 const id = crypto.randomUUID();36 const safeMetadata = entry.metadata ? redactSensitiveData(entry.metadata) : {};3738 // Non-blocking write using waitUntil39 c.executionCtx.waitUntil(40 db.prepare(`41 INSERT INTO audit_logs (id, user_id, action, resource, resource_id, ip_address, user_agent, metadata)42 VALUES (?, ?, ?, ?, ?, ?, ?, ?)43 `).bind(44 id,45 entry.userId,46 entry.action,47 entry.resource,48 entry.resourceId || null,49 entry.ipAddress || null,50 entry.userAgent || null,51 JSON.stringify(safeMetadata)52 ).run().catch(err => console.error('Audit log failed:', err))53 );54}5556export function auditLogger(options: { resource: string; action?: string }) {57 const { resource, action } = options;5859 return createMiddleware<AppEnv>(async (c, next) => {60 const startTime = Date.now();61 await next();6263 const methodAction = action || {64 GET: 'read',65 POST: 'create',66 PUT: 'update',67 PATCH: 'update',68 DELETE: 'delete',69 }[c.req.method] || c.req.method.toLowerCase();7071 await logAudit(c, {72 userId: c.get('userId') || null,73 action: methodAction,74 resource,75 resourceId: c.req.param('id'),76 ipAddress: c.req.header('CF-Connecting-IP'),77 userAgent: c.req.header('User-Agent'),78 metadata: {79 method: c.req.method,80 path: c.req.path,81 status: c.res.status,82 durationMs: Date.now() - startTime,83 },84 });85 });86}Error Handler
Centralized error handling for consistent JSON responses:
1// src/middleware/error-handler.ts2import type { Context } from "hono";3import { HTTPException } from "hono/http-exception";45export function errorHandler(err: Error, c: Context) {6 console.error(`[Error] ${err.message}`, err.stack);78 // Handle Hono HTTP exceptions9 if (err instanceof HTTPException) {10 return c.json({ error: err.message }, err.status);11 }1213 // Handle Zod validation errors14 if (err.name === "ZodError") {15 return c.json({16 error: "Validation failed",17 details: (err as unknown as { issues: unknown[] }).issues,18 }, 400);19 }2021 // Handle D1 database errors22 if (err.message?.includes("D1")) {23 return c.json({ error: "Database error" }, 503);24 }2526 // Generic error - don't leak details in production27 return c.json({ error: "Internal server error" }, 500);28}2930export function notFoundHandler(c: Context) {31 return c.json({ error: "Not found", path: c.req.path }, 404);32}Middleware Barrel Export
Combine all middleware exports for clean imports:
1// src/middleware/index.ts2export { errorHandler, notFoundHandler } from "./error-handler";34// Authentication5export {6 jwtAuth,7 optionalJwtAuth,8 generateAccessToken,9 generateRefreshToken,10 hashToken,11 JWT_CONFIG,12 type JWTPayload,13} from "./jwt-auth";1415export {16 apiKeyAuth,17 combinedAuth,18 generateApiKey,19 hashApiKey,20 extractKeyPrefix,21 API_KEY_PREFIX,22} from "./api-key-auth";2324// Rate Limiting25export {26 rateLimiter,27 kvRateLimiter,28 strictRateLimiter,29 writeRateLimiter,30 RATE_LIMIT_CONFIG,31} from "./rate-limiter";3233// Authorization (RBAC)34export {35 requireRole,36 requirePermission,37 resourceOwner,38 conditionalPermission,39 adminOnly,40 editorOrAbove,41 roleHasPermission,42 ROLE_HIERARCHY,43} from "./rbac";4445// Audit Logging46export {47 auditLogger,48 logAudit,49 createAuditLogger,50 queryAuditLogs,51 getRequestMetadata,52 AUDIT_ACTIONS,53 type AuditLogEntry,54} from "./audit-logger";Step 8: Password Hashing Utilities
Create a dedicated module for secure password hashing with PBKDF2.
1// src/lib/password.ts2const PBKDF2_ITERATIONS = 100000;3const SALT_LENGTH = 16;4const HASH_LENGTH = 256;56export async function hashPassword(password: string): Promise<string> {7 const encoder = new TextEncoder();8 const salt = crypto.getRandomValues(new Uint8Array(SALT_LENGTH));910 const keyMaterial = await crypto.subtle.importKey(11 'raw',12 encoder.encode(password),13 'PBKDF2',14 false,15 ['deriveBits']16 );1718 const hash = await crypto.subtle.deriveBits(19 {20 name: 'PBKDF2',21 salt,22 iterations: PBKDF2_ITERATIONS,23 hash: 'SHA-256',24 },25 keyMaterial,26 HASH_LENGTH27 );2829 const combined = new Uint8Array(salt.length + new Uint8Array(hash).length);30 combined.set(salt);31 combined.set(new Uint8Array(hash), salt.length);3233 return btoa(String.fromCharCode(...combined));34}3536export async function verifyPassword(37 password: string,38 storedHash: string39): Promise<boolean> {40 try {41 const combined = Uint8Array.from(atob(storedHash), (c) => c.charCodeAt(0));42 const salt = combined.slice(0, SALT_LENGTH);43 const originalHash = combined.slice(SALT_LENGTH);4445 const encoder = new TextEncoder();46 const keyMaterial = await crypto.subtle.importKey(47 'raw',48 encoder.encode(password),49 'PBKDF2',50 false,51 ['deriveBits']52 );5354 const hash = await crypto.subtle.deriveBits(55 {56 name: 'PBKDF2',57 salt,58 iterations: PBKDF2_ITERATIONS,59 hash: 'SHA-256',60 },61 keyMaterial,62 HASH_LENGTH63 );6465 const newHash = new Uint8Array(hash);6667 // Constant-time comparison to prevent timing attacks68 if (newHash.length !== originalHash.length) return false;6970 let result = 0;71 for (let i = 0; i < newHash.length; i++) {72 result |= newHash[i] ^ originalHash[i];73 }7475 return result === 0;76 } catch {77 return false;78 }79}Step 9: Auth Validation Schemas
Create Zod schemas for auth endpoint validation.
1// src/schemas/auth.ts2import { z } from 'zod';34const passwordSchema = z5 .string()6 .min(8, 'Password must be at least 8 characters')7 .regex(/[A-Z]/, 'Password must contain an uppercase letter')8 .regex(/[a-z]/, 'Password must contain a lowercase letter')9 .regex(/[0-9]/, 'Password must contain a number');1011export const registerSchema = z.object({12 email: z.string().email('Invalid email format'),13 password: passwordSchema,14});1516export const loginSchema = z.object({17 email: z.string().email('Invalid email format'),18 password: z.string().min(1, 'Password is required'),19});2021export const refreshSchema = z.object({22 refreshToken: z.string().min(1, 'Refresh token is required'),23});2425export const changePasswordSchema = z.object({26 currentPassword: z.string().min(1, 'Current password is required'),27 newPassword: passwordSchema,28});2930export type RegisterInput = z.infer<typeof registerSchema>;31export type LoginInput = z.infer<typeof loginSchema>;32export type RefreshInput = z.infer<typeof refreshSchema>;33export type ChangePasswordInput = z.infer<typeof changePasswordSchema>;Post Schema
Validation for post CRUD operations:
1// src/schemas/post.ts2import { z } from "zod";34export const createPostSchema = z.object({5 title: z.string().min(1, "Title is required").max(200, "Title too long"),6 body: z.string().min(1, "Body is required"),7 published: z.boolean().default(false),8});910export const updatePostSchema = createPostSchema.partial();1112export const listPostsQuerySchema = z.object({13 page: z.coerce.number().positive().default(1),14 limit: z.coerce.number().min(1).max(100).default(10),15 published: z.enum(["true", "false"]).optional(),16});1718export type CreatePost = z.infer<typeof createPostSchema>;19export type UpdatePost = z.infer<typeof updatePostSchema>;20export type ListPostsQuery = z.infer<typeof listPostsQuerySchema>;Schemas Barrel Export
1// src/schemas/index.ts2export * from "./post";Step 10: Auth Routes (Modular Structure)
Split auth routes into separate files for clean, maintainable code.
1src/routes/auth/2├── index.ts # Combines all routes3├── register.ts # POST /auth/register4├── login.ts # POST /auth/login5├── refresh.ts # POST /auth/refresh6├── logout.ts # POST /auth/logout7├── me.ts # GET /auth/me8└── password.ts # PUT /auth/passwordRegister Route
1// src/routes/auth/register.ts2import { Hono } from 'hono';3import { zValidator } from '@hono/zod-validator';4import { HTTPException } from 'hono/http-exception';56import type { AppEnv } from '../../types/bindings';7import { hashPassword } from '../../lib/password';8import { registerSchema } from '../../schemas/auth';9import { strictRateLimiter } from '../../middleware/rate-limiter';10import { createAuditLogger, AUDIT_ACTIONS } from '../../middleware/audit-logger';1112const register = new Hono<AppEnv>();1314register.post('/', strictRateLimiter(), zValidator('json', registerSchema), async (c) => {15 const { email, password } = c.req.valid('json');16 const audit = createAuditLogger(c);1718 const existing = await c.env.DB.prepare('SELECT id FROM users WHERE email = ?')19 .bind(email).first();2021 if (existing) {22 throw new HTTPException(409, { message: 'User with this email already exists' });23 }2425 const id = crypto.randomUUID();26 const passwordHash = await hashPassword(password);2728 await c.env.DB.prepare(29 'INSERT INTO users (id, email, password_hash, role) VALUES (?, ?, ?, ?)'30 ).bind(id, email, passwordHash, 'user').run();3132 audit.log(AUDIT_ACTIONS.USER_CREATE, 'users', id, { email, role: 'user' });3334 return c.json({ id, email, message: 'User created successfully' }, 201);35});3637export { register };Login Route
1// src/routes/auth/login.ts2import { Hono } from 'hono';3import { zValidator } from '@hono/zod-validator';4import { HTTPException } from 'hono/http-exception';56import type { AppEnv } from '../../types/bindings';7import { verifyPassword } from '../../lib/password';8import { loginSchema } from '../../schemas/auth';9import { generateAccessToken, generateRefreshToken, hashToken } from '../../middleware/jwt-auth';10import { strictRateLimiter } from '../../middleware/rate-limiter';11import { createAuditLogger, AUDIT_ACTIONS } from '../../middleware/audit-logger';1213const login = new Hono<AppEnv>();1415login.post('/', strictRateLimiter(), zValidator('json', loginSchema), async (c) => {16 const { email, password } = c.req.valid('json');17 const audit = createAuditLogger(c);1819 const user = await c.env.DB.prepare(20 'SELECT id, email, password_hash, role, is_active FROM users WHERE email = ?'21 ).bind(email).first();2223 if (!user) {24 audit.log(AUDIT_ACTIONS.LOGIN_FAILED, 'auth', undefined, { email, reason: 'user_not_found' });25 throw new HTTPException(401, { message: 'Invalid credentials' });26 }2728 if (!user.is_active) {29 audit.log(AUDIT_ACTIONS.LOGIN_FAILED, 'auth', user.id as string, { email, reason: 'account_disabled' });30 throw new HTTPException(403, { message: 'Account is disabled' });31 }3233 const isValid = await verifyPassword(password, user.password_hash as string);34 if (!isValid) {35 audit.log(AUDIT_ACTIONS.LOGIN_FAILED, 'auth', user.id as string, { email, reason: 'invalid_password' });36 throw new HTTPException(401, { message: 'Invalid credentials' });37 }3839 const secret = c.env.JWT_SECRET;40 if (!secret) {41 throw new HTTPException(500, { message: 'Server configuration error' });42 }4344 const accessToken = await generateAccessToken({45 id: user.id as string,46 email: user.email as string,47 role: user.role as string,48 }, secret);4950 const familyId = crypto.randomUUID();51 const refreshToken = await generateRefreshToken(user.id as string, familyId, secret);5253 const refreshTokenHash = await hashToken(refreshToken);54 const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString();5556 await c.env.DB.prepare(57 'INSERT INTO refresh_tokens (id, user_id, token_hash, family_id, expires_at) VALUES (?, ?, ?, ?, ?)'58 ).bind(crypto.randomUUID(), user.id, refreshTokenHash, familyId, expiresAt).run();5960 audit.log(AUDIT_ACTIONS.LOGIN, 'auth', user.id as string, { email });6162 return c.json({63 accessToken,64 refreshToken,65 tokenType: 'Bearer',66 expiresIn: 900,67 user: { id: user.id, email: user.email, role: user.role },68 });69});7071export { login };Logout Route
1// src/routes/auth/logout.ts2import { Hono } from 'hono';34import type { AppEnv } from '../../types/bindings';5import { jwtAuth } from '../../middleware/jwt-auth';6import { createAuditLogger, AUDIT_ACTIONS } from '../../middleware/audit-logger';78const logout = new Hono<AppEnv>();910logout.post('/', jwtAuth(), async (c) => {11 const userId = c.get('userId');12 const audit = createAuditLogger(c);1314 await c.env.DB.prepare(15 "UPDATE refresh_tokens SET revoked_at = datetime('now') WHERE user_id = ? AND revoked_at IS NULL"16 ).bind(userId).run();1718 audit.log(AUDIT_ACTIONS.LOGOUT, 'auth', userId);1920 return c.json({ message: 'Logged out successfully' });21});2223export { logout };Me Route
1// src/routes/auth/me.ts2import { Hono } from 'hono';3import { HTTPException } from 'hono/http-exception';45import type { AppEnv } from '../../types/bindings';6import { jwtAuth } from '../../middleware/jwt-auth';78const me = new Hono<AppEnv>();910me.get('/', jwtAuth(), async (c) => {11 const userId = c.get('userId');1213 const user = await c.env.DB.prepare(14 'SELECT id, email, role, created_at FROM users WHERE id = ?'15 ).bind(userId).first();1617 if (!user) {18 throw new HTTPException(404, { message: 'User not found' });19 }2021 return c.json(user);22});2324export { me };Password Route
1// src/routes/auth/password.ts2import { Hono } from 'hono';3import { zValidator } from '@hono/zod-validator';4import { HTTPException } from 'hono/http-exception';56import type { AppEnv } from '../../types/bindings';7import { hashPassword, verifyPassword } from '../../lib/password';8import { changePasswordSchema } from '../../schemas/auth';9import { jwtAuth } from '../../middleware/jwt-auth';10import { createAuditLogger, AUDIT_ACTIONS } from '../../middleware/audit-logger';1112const password = new Hono<AppEnv>();1314password.put('/', jwtAuth(), zValidator('json', changePasswordSchema), async (c) => {15 const userId = c.get('userId');16 const { currentPassword, newPassword } = c.req.valid('json');17 const audit = createAuditLogger(c);1819 const user = await c.env.DB.prepare(20 'SELECT password_hash FROM users WHERE id = ?'21 ).bind(userId).first();2223 if (!user) {24 throw new HTTPException(404, { message: 'User not found' });25 }2627 const isValid = await verifyPassword(currentPassword, user.password_hash as string);28 if (!isValid) {29 throw new HTTPException(401, { message: 'Current password is incorrect' });30 }3132 const newHash = await hashPassword(newPassword);3334 await c.env.DB.prepare(35 "UPDATE users SET password_hash = ?, updated_at = datetime('now') WHERE id = ?"36 ).bind(newHash, userId).run();3738 await c.env.DB.prepare(39 "UPDATE refresh_tokens SET revoked_at = datetime('now') WHERE user_id = ?"40 ).bind(userId).run();4142 audit.log(AUDIT_ACTIONS.PASSWORD_CHANGE, 'auth', userId);4344 return c.json({ message: 'Password updated successfully' });45});4647export { password };Auth Index (Combines Routes)
1// src/routes/auth/index.ts2import { Hono } from 'hono';3import type { AppEnv } from '../../types/bindings';45import { register } from './register';6import { login } from './login';7import { refresh } from './refresh';8import { logout } from './logout';9import { me } from './me';10import { password } from './password';1112const auth = new Hono<AppEnv>();1314auth.route('/register', register);15auth.route('/login', login);16auth.route('/refresh', refresh);17auth.route('/logout', logout);18auth.route('/me', me);19auth.route('/password', password);2021export { auth };Refresh Route (Token Rotation)
1// src/routes/auth/refresh.ts2import { Hono } from 'hono';3import { zValidator } from '@hono/zod-validator';4import { HTTPException } from 'hono/http-exception';5import { verify } from 'hono/jwt';67import type { AppEnv } from '../../types/bindings';8import { refreshSchema } from '../../schemas/auth';9import { generateAccessToken, generateRefreshToken, hashToken } from '../../middleware/jwt-auth';10import { strictRateLimiter } from '../../middleware/rate-limiter';11import { createAuditLogger, AUDIT_ACTIONS } from '../../middleware/audit-logger';1213const refresh = new Hono<AppEnv>();1415refresh.post('/', strictRateLimiter(), zValidator('json', refreshSchema), async (c) => {16 const { refreshToken } = c.req.valid('json');17 const audit = createAuditLogger(c);18 const secret = c.env.JWT_SECRET;1920 // Verify token21 let payload;22 try {23 payload = await verify(refreshToken, secret, 'HS256');24 } catch {25 throw new HTTPException(401, { message: 'Invalid refresh token' });26 }2728 if ((payload as { type?: string }).type !== 'refresh') {29 throw new HTTPException(401, { message: 'Invalid token type' });30 }3132 // Find token in database33 const tokenHash = await hashToken(refreshToken);34 const storedToken = await c.env.DB.prepare(`35 SELECT rt.*, u.email, u.role, u.is_active36 FROM refresh_tokens rt37 JOIN users u ON rt.user_id = u.id38 WHERE rt.token_hash = ? AND rt.revoked_at IS NULL AND rt.expires_at > datetime('now')39 `).bind(tokenHash).first();4041 if (!storedToken) {42 // Possible token reuse attack - revoke entire family43 if (payload.family) {44 await c.env.DB.prepare(45 "UPDATE refresh_tokens SET revoked_at = datetime('now') WHERE family_id = ?"46 ).bind(payload.family).run();4748 audit.log(AUDIT_ACTIONS.TOKEN_REFRESH, 'auth', payload.sub as string, {49 reason: 'token_reuse_detected',50 family_id: payload.family,51 });52 }53 throw new HTTPException(401, { message: 'Invalid or revoked refresh token' });54 }5556 if (!storedToken.is_active) {57 throw new HTTPException(403, { message: 'Account is disabled' });58 }5960 // Revoke old token61 await c.env.DB.prepare(62 "UPDATE refresh_tokens SET revoked_at = datetime('now') WHERE id = ?"63 ).bind(storedToken.id).run();6465 // Generate new tokens (rotation)66 const accessToken = await generateAccessToken({67 id: storedToken.user_id as string,68 email: storedToken.email as string,69 role: storedToken.role as string,70 }, secret);7172 const newRefreshToken = await generateRefreshToken(73 storedToken.user_id as string,74 storedToken.family_id as string,75 secret76 );7778 // Store new token79 const newTokenHash = await hashToken(newRefreshToken);80 const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString();8182 await c.env.DB.prepare(83 'INSERT INTO refresh_tokens (id, user_id, token_hash, family_id, expires_at) VALUES (?, ?, ?, ?, ?)'84 ).bind(crypto.randomUUID(), storedToken.user_id, newTokenHash, storedToken.family_id, expiresAt).run();8586 audit.log(AUDIT_ACTIONS.TOKEN_REFRESH, 'auth', storedToken.user_id as string);8788 return c.json({89 accessToken,90 refreshToken: newRefreshToken,91 tokenType: 'Bearer',92 expiresIn: 900,93 });94});9596export { refresh };Step 10b: Posts Route
CRUD operations for posts:
1// src/routes/posts.ts2import { Hono } from 'hono';3import { zValidator } from '@hono/zod-validator';4import { HTTPException } from 'hono/http-exception';5import type { AppEnv } from '../types/bindings';6import { createPostSchema, updatePostSchema, listPostsQuerySchema } from '../schemas';78const posts = new Hono<AppEnv>();910// GET /posts - List all posts with pagination11posts.get('/', zValidator('query', listPostsQuerySchema), async (c) => {12 const { page, limit, published } = c.req.valid('query');13 const offset = (page - 1) * limit;1415 let query = 'SELECT * FROM posts';16 const params: (string | number)[] = [];1718 if (published !== undefined) {19 query += ' WHERE published = ?';20 params.push(published === 'true' ? 1 : 0);21 }2223 query += ' ORDER BY created_at DESC LIMIT ? OFFSET ?';24 params.push(limit, offset);2526 const { results } = await c.env.DB.prepare(query).bind(...params).all();27 return c.json({ posts: results, page, limit });28});2930// GET /posts/:id - Get single post31posts.get('/:id', async (c) => {32 const id = c.req.param('id');33 const post = await c.env.DB.prepare('SELECT * FROM posts WHERE id = ?').bind(id).first();3435 if (!post) {36 throw new HTTPException(404, { message: 'Post not found' });37 }38 return c.json(post);39});4041// POST /posts - Create new post42posts.post('/', zValidator('json', createPostSchema), async (c) => {43 const data = c.req.valid('json');44 const id = crypto.randomUUID();4546 await c.env.DB.prepare(47 'INSERT INTO posts (id, title, body, published) VALUES (?, ?, ?, ?)'48 ).bind(id, data.title, data.body, data.published ? 1 : 0).run();4950 return c.json({ id, ...data }, 201);51});5253// PUT /posts/:id - Update post54posts.put('/:id', zValidator('json', updatePostSchema), async (c) => {55 const id = c.req.param('id');56 const data = c.req.valid('json');5758 const existing = await c.env.DB.prepare('SELECT * FROM posts WHERE id = ?').bind(id).first();59 if (!existing) {60 throw new HTTPException(404, { message: 'Post not found' });61 }6263 await c.env.DB.prepare(`64 UPDATE posts SET title = ?, body = ?, published = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?65 `).bind(66 data.title ?? existing.title,67 data.body ?? existing.body,68 data.published !== undefined ? (data.published ? 1 : 0) : existing.published,69 id70 ).run();7172 return c.json({ id, ...data });73});7475// DELETE /posts/:id - Delete post76posts.delete('/:id', async (c) => {77 const id = c.req.param('id');78 const result = await c.env.DB.prepare('DELETE FROM posts WHERE id = ?').bind(id).run();7980 if (result.meta.changes === 0) {81 throw new HTTPException(404, { message: 'Post not found' });82 }83 return c.json({ deleted: true });84});8586export { posts };Step 10c: API Keys Route
Manage API keys for server-to-server authentication:
1// src/routes/api-keys.ts2import { Hono } from 'hono';3import { zValidator } from '@hono/zod-validator';4import { HTTPException } from 'hono/http-exception';5import { z } from 'zod';6import type { AppEnv } from '../types/bindings';7import { jwtAuth } from '../middleware/jwt-auth';8import { requireRole } from '../middleware/rbac';9import { generateApiKey, hashApiKey } from '../middleware/api-key-auth';10import { createAuditLogger, AUDIT_ACTIONS } from '../middleware/audit-logger';1112const apiKeys = new Hono<AppEnv>();1314// All routes require JWT authentication15apiKeys.use('*', jwtAuth());1617const createApiKeySchema = z.object({18 name: z.string().min(1).max(100),19 scopes: z.array(z.string()).default(['posts:read']),20 expiresInDays: z.number().min(1).max(365).optional(),21});2223// GET /api-keys - List user's API keys24apiKeys.get('/', async (c) => {25 const userId = c.get('userId');2627 const { results } = await c.env.DB.prepare(`28 SELECT id, key_prefix, name, scopes, rate_limit, last_used_at, expires_at, created_at29 FROM api_keys WHERE user_id = ? AND revoked_at IS NULL ORDER BY created_at DESC30 `).bind(userId).all();3132 const keys = (results || []).map((key) => ({33 ...key,34 scopes: JSON.parse((key.scopes as string) || '[]'),35 }));3637 return c.json({ apiKeys: keys, page: 1, limit: 20 });38});3940// POST /api-keys - Create new API key41apiKeys.post('/', zValidator('json', createApiKeySchema), async (c) => {42 const userId = c.get('userId');43 const { name, scopes, expiresInDays } = c.req.valid('json');44 const audit = createAuditLogger(c);4546 const { key, prefix } = await generateApiKey('secret', true);47 const keyHash = await hashApiKey(key);48 const id = crypto.randomUUID();4950 const expiresAt = expiresInDays51 ? new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000).toISOString()52 : null;5354 await c.env.DB.prepare(`55 INSERT INTO api_keys (id, user_id, key_prefix, key_hash, name, scopes, expires_at)56 VALUES (?, ?, ?, ?, ?, ?, ?)57 `).bind(id, userId, prefix, keyHash, name, JSON.stringify(scopes), expiresAt).run();5859 audit.log(AUDIT_ACTIONS.API_KEY_CREATE, 'api_keys', id, { name, scopes });6061 return c.json({62 id,63 key, // Full key - only shown once!64 keyPrefix: prefix,65 name,66 scopes,67 expiresAt,68 message: 'Store this API key securely. You will not be able to see it again.',69 }, 201);70});7172// DELETE /api-keys/:id - Revoke API key73apiKeys.delete('/:id', async (c) => {74 const userId = c.get('userId');75 const userRole = c.get('userRole');76 const keyId = c.req.param('id');77 const audit = createAuditLogger(c);7879 const existing = await c.env.DB.prepare(80 'SELECT user_id FROM api_keys WHERE id = ? AND revoked_at IS NULL'81 ).bind(keyId).first();8283 if (!existing) {84 throw new HTTPException(404, { message: 'API key not found' });85 }8687 if (existing.user_id !== userId && userRole !== 'admin') {88 throw new HTTPException(403, { message: 'You can only revoke your own API keys' });89 }9091 await c.env.DB.prepare(92 "UPDATE api_keys SET revoked_at = datetime('now') WHERE id = ?"93 ).bind(keyId).run();9495 audit.log(AUDIT_ACTIONS.API_KEY_REVOKE, 'api_keys', keyId);9697 return c.json({ message: 'API key revoked', id: keyId });98});99100// GET /api-keys/admin/all - Admin only: list all keys101apiKeys.get('/admin/all', requireRole('admin'), async (c) => {102 const { results } = await c.env.DB.prepare(`103 SELECT ak.id, ak.key_prefix, ak.name, ak.scopes, ak.last_used_at, ak.created_at, u.email as user_email104 FROM api_keys ak JOIN users u ON ak.user_id = u.id105 WHERE ak.revoked_at IS NULL ORDER BY ak.created_at DESC LIMIT 100106 `).all();107108 const keys = (results || []).map((key) => ({109 ...key,110 scopes: JSON.parse((key.scopes as string) || '[]'),111 }));112113 return c.json({ apiKeys: keys });114});115116export { apiKeys };Step 10d: Routes Barrel Export
Combine all route exports:
1// src/routes/index.ts2export { posts } from './posts';3export { auth } from './auth';4export { apiKeys } from './api-keys';Step 11: Wire Everything Together
Update your main app entry point.
1// src/index.ts2import { Hono } from 'hono';3import { cors } from 'hono/cors';4import { logger } from 'hono/logger';5import { secureHeaders } from 'hono/secure-headers';67import type { AppEnv } from './types/bindings';8import { posts, auth, apiKeys } from './routes';9import {10 errorHandler,11 notFoundHandler,12 rateLimiter,13 combinedAuth,14 auditLogger,15} from './middleware';1617const app = new Hono<AppEnv>();1819// Request ID20app.use('*', async (c, next) => {21 c.set('requestId', crypto.randomUUID());22 await next();23});2425// Global middleware26app.use('*', logger());27app.use('*', secureHeaders({28 contentSecurityPolicy: {29 defaultSrc: ["'none'"],30 frameAncestors: ["'none'"],31 },32 xFrameOptions: 'DENY',33}));3435// CORS36app.use('/api/*', cors({37 origin: ['https://example.com'],38 credentials: true,39 allowHeaders: ['Content-Type', 'Authorization', 'X-API-Key'],40 exposeHeaders: ['X-RateLimit-Limit', 'X-RateLimit-Remaining'],41}));4243// Rate limiting44app.use('/api/*', rateLimiter());4546// Health check47app.get('/health', (c) => c.json({ status: 'ok', requestId: c.get('requestId') }));4849// Auth routes (public)50app.route('/api/auth', auth);5152// Posts - protected write operations53const protectedPosts = new Hono<AppEnv>();54protectedPosts.use('*', combinedAuth());55protectedPosts.use('*', auditLogger({ resource: 'posts' }));56protectedPosts.post('/*', (c) => posts.fetch(c.req.raw, c.env, c.executionCtx));57protectedPosts.put('/*', (c) => posts.fetch(c.req.raw, c.env, c.executionCtx));58protectedPosts.delete('/*', (c) => posts.fetch(c.req.raw, c.env, c.executionCtx));5960app.route('/api/posts', protectedPosts);61app.route('/api/posts', posts); // Public GET6263// API Keys management64app.route('/api/api-keys', apiKeys);6566// Error handling67app.onError(errorHandler);68app.notFound(notFoundHandler);6970export default app;Step 12: Environment Configuration
Configure secrets and environment variables for development and production.
Generate a Secure Secret
Local Development (.dev.vars)
First, ensure .dev.vars is in your .gitignore:
1# .gitignore2.dev.vars3.wrangler4node_modules5distCopy the example file and add your secret:
1# .dev.vars (gitignored - never commit this file!)23# JWT Secret for signing tokens (minimum 32 characters)4JWT_SECRET=your-super-secret-jwt-key-min-32-chars-hereProduction (Wrangler Secrets)
For production, use Cloudflare's encrypted secrets storage:
wrangler.toml Configuration
1# wrangler.toml2name = "hono-api"3main = "src/index.ts"4compatibility_date = "2024-12-01"56# D1 Database7[[d1_databases]]8binding = "DB"9database_name = "my-database"10database_id = "your-database-id-here"1112# Rate Limiting (Cloudflare Pro+)13# [[unsafe.bindings]]14# name = "RATE_LIMITER"15# type = "ratelimit"16# namespace_id = "1001"17# simple = { limit = 100, period = 60 }1819# Environment Variables (non-secret)20[vars]21ENVIRONMENT = "production"2223# Secret Variables (set via wrangler secret put)24# JWT_SECRET - Run: wrangler secret put JWT_SECRET2526# Development Settings27[dev]28port = 513829local_protocol = "http"Testing Configuration (vitest.config.ts)
Tests need access to JWT_SECRET but shouldn't rely on your .dev.vars file. The vitest config provides test-specific bindings via Miniflare:
1// vitest.config.ts2import { defineWorkersConfig } from "@cloudflare/vitest-pool-workers/config";34export default defineWorkersConfig({5 test: {6 globals: true,7 setupFiles: ["./tests/setup.ts"],8 poolOptions: {9 workers: {10 wrangler: { configPath: "./wrangler.toml" },11 miniflare: {12 // In-memory D1 database for tests13 d1Databases: {14 DB: "test-db",15 },16 // Test-specific bindings (override secrets for testing)17 bindings: {18 JWT_SECRET: "test-jwt-secret-for-testing-only-32chars",19 },20 },21 singleWorker: true,22 isolatedStorage: false,23 },24 },25 },26});Step 13: Testing Your Secured API
A well-organized test suite mirrors your source structure. This makes tests easy to find and maintain as your API grows.
Test Folder Structure
1tests/2├── setup.ts # Global test setup3├── cloudflare-test.d.ts # Type definitions4├── helpers/5│ └── auth.ts # Shared test utilities6├── routes/7│ ├── auth/8│ │ ├── register.test.ts # POST /auth/register9│ │ ├── login.test.ts # POST /auth/login10│ │ ├── logout.test.ts # POST /auth/logout11│ │ └── me.test.ts # GET /auth/me12│ ├── posts/13│ │ └── posts.test.ts # Posts CRUD tests14│ └── api-keys/15│ └── api-keys.test.ts # API key management16└── integration/17 └── health.test.ts # Health check & 404Test Type Definitions
First, extend the Cloudflare test types with your bindings:
1// tests/cloudflare-test.d.ts2import type { Bindings } from "../src/types/bindings";34declare module "cloudflare:test" {5 // eslint-disable-next-line @typescript-eslint/no-empty-object-type6 interface ProvidedEnv extends Bindings {}7}Test Setup (Database Schema)
Create tables and seed data before tests run:
1// tests/setup.ts2import { env } from "cloudflare:test";3import { beforeAll } from "vitest";45beforeAll(async () => {6 // Users table7 await env.DB.exec(`8 CREATE TABLE IF NOT EXISTS users (9 id TEXT PRIMARY KEY,10 email TEXT UNIQUE NOT NULL,11 password_hash TEXT NOT NULL,12 role TEXT NOT NULL DEFAULT 'user',13 is_active INTEGER DEFAULT 1,14 created_at TEXT DEFAULT CURRENT_TIMESTAMP,15 updated_at TEXT DEFAULT CURRENT_TIMESTAMP16 )17 `);1819 // Refresh tokens table20 await env.DB.exec(`21 CREATE TABLE IF NOT EXISTS refresh_tokens (22 id TEXT PRIMARY KEY,23 user_id TEXT NOT NULL,24 token_hash TEXT UNIQUE NOT NULL,25 family_id TEXT NOT NULL,26 expires_at TEXT NOT NULL,27 created_at TEXT DEFAULT CURRENT_TIMESTAMP,28 revoked_at TEXT DEFAULT NULL29 )30 `);3132 // API keys table33 await env.DB.exec(`34 CREATE TABLE IF NOT EXISTS api_keys (35 id TEXT PRIMARY KEY,36 user_id TEXT NOT NULL,37 key_prefix TEXT NOT NULL,38 key_hash TEXT UNIQUE NOT NULL,39 name TEXT NOT NULL,40 scopes TEXT NOT NULL DEFAULT '[]',41 rate_limit INTEGER DEFAULT 1000,42 last_used_at TEXT DEFAULT NULL,43 expires_at TEXT DEFAULT NULL,44 created_at TEXT DEFAULT CURRENT_TIMESTAMP,45 revoked_at TEXT DEFAULT NULL46 )47 `);4849 // Posts table50 await env.DB.exec(`51 CREATE TABLE IF NOT EXISTS posts (52 id TEXT PRIMARY KEY,53 title TEXT NOT NULL,54 body TEXT NOT NULL,55 published INTEGER DEFAULT 0,56 created_at TEXT DEFAULT CURRENT_TIMESTAMP,57 updated_at TEXT DEFAULT CURRENT_TIMESTAMP58 )59 `);6061 // Audit logs table62 await env.DB.exec(`63 CREATE TABLE IF NOT EXISTS audit_logs (64 id TEXT PRIMARY KEY,65 user_id TEXT,66 action TEXT NOT NULL,67 resource TEXT NOT NULL,68 resource_id TEXT,69 ip_address TEXT,70 user_agent TEXT,71 metadata TEXT DEFAULT '{}',72 created_at TEXT DEFAULT CURRENT_TIMESTAMP73 )74 `);7576 // Roles & permissions tables77 await env.DB.exec(`CREATE TABLE IF NOT EXISTS roles (id TEXT PRIMARY KEY, name TEXT UNIQUE NOT NULL, description TEXT, created_at TEXT DEFAULT CURRENT_TIMESTAMP)`);78 await env.DB.exec(`CREATE TABLE IF NOT EXISTS permissions (id TEXT PRIMARY KEY, name TEXT UNIQUE NOT NULL, resource TEXT NOT NULL, action TEXT NOT NULL, created_at TEXT DEFAULT CURRENT_TIMESTAMP)`);79 await env.DB.exec(`CREATE TABLE IF NOT EXISTS role_permissions (role_id TEXT NOT NULL, permission_id TEXT NOT NULL, PRIMARY KEY (role_id, permission_id))`);8081 // Seed default roles82 await env.DB.exec("INSERT OR IGNORE INTO roles (id, name, description) VALUES ('role_admin', 'admin', 'Full administrative access')");83 await env.DB.exec("INSERT OR IGNORE INTO roles (id, name, description) VALUES ('role_editor', 'editor', 'Can create and edit content')");84 await env.DB.exec("INSERT OR IGNORE INTO roles (id, name, description) VALUES ('role_viewer', 'viewer', 'Read-only access')");8586 // Seed default permissions87 await env.DB.exec("INSERT OR IGNORE INTO permissions (id, name, resource, action) VALUES ('perm_posts_create', 'posts:create', 'posts', 'create')");88 await env.DB.exec("INSERT OR IGNORE INTO permissions (id, name, resource, action) VALUES ('perm_posts_read', 'posts:read', 'posts', 'read')");89 await env.DB.exec("INSERT OR IGNORE INTO permissions (id, name, resource, action) VALUES ('perm_posts_update', 'posts:update', 'posts', 'update')");90 await env.DB.exec("INSERT OR IGNORE INTO permissions (id, name, resource, action) VALUES ('perm_posts_delete', 'posts:delete', 'posts', 'delete')");91});Shared Test Helpers
Extract common utilities to avoid repetition:
1// tests/helpers/auth.ts2import { env } from "cloudflare:test";3import app from "../../src/index";45export const headers = {6 "Content-Type": "application/json",7 Origin: "http://localhost",8};910export type LoginResponse = {11 accessToken: string;12 refreshToken: string;13 user: { id: string; email: string; role: string };14};1516export const uniqueEmail = () =>17 `test-${Date.now()}-${Math.random().toString(36).slice(2)}@example.com`;1819export async function getAuthToken(): Promise<string> {20 const email = uniqueEmail();2122 await app.request("/api/auth/register", {23 method: "POST",24 headers,25 body: JSON.stringify({ email, password: "SecurePass123" }),26 }, env);2728 const loginRes = await app.request("/api/auth/login", {29 method: "POST",30 headers,31 body: JSON.stringify({ email, password: "SecurePass123" }),32 }, env);3334 const data = (await loginRes.json()) as LoginResponse;35 return data.accessToken;36}Example: Auth Route Tests
1// tests/routes/auth/register.test.ts2import { describe, it, expect } from "vitest";3import { env } from "cloudflare:test";4import app from "../../../src/index";5import { headers, uniqueEmail } from "../../helpers/auth";67describe("Auth - Register", () => {8 it("creates a new user with valid data", async () => {9 const email = uniqueEmail();10 const res = await app.request("/api/auth/register", {11 method: "POST",12 headers,13 body: JSON.stringify({ email, password: "SecurePass123" }),14 }, env);1516 expect(res.status).toBe(201);17 const data = await res.json();18 expect(data.email).toBe(email);19 expect(data.id).toBeDefined();20 });2122 it("rejects weak password", async () => {23 const res = await app.request("/api/auth/register", {24 method: "POST",25 headers,26 body: JSON.stringify({ email: uniqueEmail(), password: "weak" }),27 }, env);2829 expect(res.status).toBe(400);30 });3132 it("rejects duplicate email", async () => {33 const email = uniqueEmail();3435 // Register first time36 await app.request("/api/auth/register", {37 method: "POST",38 headers,39 body: JSON.stringify({ email, password: "SecurePass123" }),40 }, env);4142 // Try again - should fail43 const res = await app.request("/api/auth/register", {44 method: "POST",45 headers,46 body: JSON.stringify({ email, password: "SecurePass123" }),47 }, env);4849 expect(res.status).toBe(409);50 });51});Example: Protected Route Tests
1// tests/routes/auth/me.test.ts2import { describe, it, expect } from "vitest";3import { env } from "cloudflare:test";4import app from "../../../src/index";5import { headers, getAuthToken } from "../../helpers/auth";67describe("Auth - Me", () => {8 it("returns user info with valid token", async () => {9 const token = await getAuthToken();1011 const res = await app.request("/api/auth/me", {12 headers: { ...headers, Authorization: `Bearer ${token}` },13 }, env);1415 expect(res.status).toBe(200);16 const data = await res.json();17 expect(data.email).toBeDefined();18 expect(data.role).toBe("user");19 });2021 it("rejects missing token", async () => {22 const res = await app.request("/api/auth/me", { headers }, env);23 expect(res.status).toBe(401);24 });2526 it("rejects invalid token", async () => {27 const res = await app.request("/api/auth/me", {28 headers: { ...headers, Authorization: "Bearer invalid-token" },29 }, env);30 expect(res.status).toBe(401);31 });32});Run Tests
Manual Testing with cURL
Best Practices Recap
Continue the Series
You are here
Secure Your API
Previous
Build a REST API
Next
Coming soon
Next Steps
Starter Repository v2.0.0
Complete source code with all security features
Hono JWT Middleware
Official JWT middleware documentation
Cloudflare Rate Limiting
Rate Limiting API documentation
OWASP API Security
API security best practices
Brisbane/Queensland Context
This security architecture protects APIs for Queensland fintech, healthcare, and government clients. With edge-computed authentication at Cloudflare's Sydney and Melbourne POPs, auth checks add minimal latency while providing enterprise-grade security.
Need help securing your API?
Topics
Comments
Sign in to join the conversation
LoginNo comments yet. Be the first to share your thoughts!
Found an issue with this article?
