Skip to main content
/ Tutorial

Secure Your Hono API: JWT Auth, Rate Limiting & RBAC

Sacha Roussakis-NotterSacha Roussakis-Notter
30 min read
Hono
Cloudflare
TypeScript
Source
Share

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.

Hono API Tutorial Series
Part 2 of 2
2

You are here

Secure Your API

Tutorial Seriesbuun.group

Security Features Overview

FeaturePurpose
JWT AuthenticationShort-lived access tokens + refresh token rotation
API KeysServer-to-server integration with scopes
Rate LimitingProtect against abuse (60 req/min default)
RBACAdmin, Editor, Viewer roles with permissions
Security HeadersHSTS, CSP, XSS protection
Audit LoggingTrack all actions with waitUntil

Prerequisites

Before You Begin
0/4
0% completebuun.group

Project Configuration

Before adding security features, ensure your project has the correct configuration.

package.json

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

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": true
20 },
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.

sql
1-- migrations/0002_create_auth_tables.sql
2
3-- Users table
4CREATE 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_TIMESTAMP
12);
13
14-- Refresh tokens for JWT rotation
15CREATE 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 NULL
23);
24
25-- API keys for server-to-server auth
26CREATE 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 NULL
38);
39
40-- Roles for RBAC
41CREATE 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_TIMESTAMP
46);
47
48-- Permissions for RBAC
49CREATE 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_TIMESTAMP
55);
56
57-- Role-permission junction
58CREATE 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);
63
64-- Audit logs
65CREATE 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_TIMESTAMP
75);
76
77-- Create indexes
78CREATE 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);
83
84-- Seed default roles and permissions
85INSERT OR IGNORE INTO roles (id, name, description) VALUES
86 ('role_admin', 'admin', 'Full administrative access'),
87 ('role_editor', 'editor', 'Can create and edit content'),
88 ('role_viewer', 'viewer', 'Read-only access');
89
90INSERT OR IGNORE INTO permissions (id, name, resource, action) VALUES
91 ('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');
Apply Migration
$npm run db:migrate:local
All migrations applied
1 commandbuun.group

Step 2: Update Type Definitions

Add new context variables for authenticated users.

typescript
1// src/types/bindings.ts
2export 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};
10
11export type Variables = {
12 requestId: string;
13 userId?: string;
14 userEmail?: string;
15 userRole?: string;
16 apiKeyId?: string;
17 apiKeyScopes?: string[];
18};
19
20export 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.

typescript
1// src/middleware/jwt-auth.ts
2import { createMiddleware } from 'hono/factory';
3import { sign, verify } from 'hono/jwt';
4import { HTTPException } from 'hono/http-exception';
5import type { AppEnv } from '../types/bindings';
6
7export const JWT_CONFIG = {
8 accessTokenExpiry: 15 * 60, // 15 minutes
9 refreshTokenExpiry: 7 * 24 * 60 * 60, // 7 days
10 algorithm: 'HS256' as const,
11};
12
13export 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}
22
23export async function generateAccessToken(
24 user: { id: string; email: string; role: string },
25 secret: string
26): 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}
37
38export 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}
45
46export function jwtAuth() {
47 return createMiddleware<AppEnv>(async (c, next) => {
48 const authHeader = c.req.header('Authorization');
49
50 if (!authHeader?.startsWith('Bearer ')) {
51 throw new HTTPException(401, {
52 message: 'Missing or invalid Authorization header',
53 });
54 }
55
56 const token = authHeader.slice(7);
57 const secret = c.env.JWT_SECRET;
58
59 if (!secret) {
60 throw new HTTPException(500, { message: 'Server configuration error' });
61 }
62
63 try {
64 const payload = await verify(token, secret, JWT_CONFIG.algorithm) as unknown as JWTPayload;
65
66 if (payload.type !== 'access') {
67 throw new HTTPException(401, { message: 'Invalid token type' });
68 }
69
70 c.set('userId', payload.sub);
71 c.set('userEmail', payload.email);
72 c.set('userRole', payload.role);
73
74 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.

typescript
1// src/middleware/api-key-auth.ts
2import { createMiddleware } from 'hono/factory';
3import { HTTPException } from 'hono/http-exception';
4import type { AppEnv } from '../types/bindings';
5
6export const API_KEY_PREFIX = {
7 secretLive: 'sk_live_',
8 secretTest: 'sk_test_',
9};
10
11export async function generateApiKey(
12 type: 'secret' = 'secret',
13 isLive: boolean = true
14): 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}
24
25export 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}
32
33export function apiKeyAuth(options?: { scopes?: string[] }) {
34 return createMiddleware<AppEnv>(async (c, next) => {
35 const apiKey = c.req.header('X-API-Key');
36
37 if (!apiKey) {
38 throw new HTTPException(401, { message: 'Missing X-API-Key header' });
39 }
40
41 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_active
44 FROM api_keys ak
45 JOIN users u ON ak.user_id = u.id
46 WHERE ak.key_hash = ?
47 AND ak.revoked_at IS NULL
48 AND (ak.expires_at IS NULL OR ak.expires_at > datetime('now'))
49 `).bind(keyHash).first();
50
51 if (!keyRecord) {
52 throw new HTTPException(401, { message: 'Invalid or expired API key' });
53 }
54
55 if (!keyRecord.is_active) {
56 throw new HTTPException(403, { message: 'User account is disabled' });
57 }
58
59 // Check scopes
60 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 }
67
68 // 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 );
74
75 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 || '[]'));
80
81 await next();
82 });
83}

Step 5: Rate Limiting

Protect your API from abuse with configurable rate limits.

typescript
1// src/middleware/rate-limiter.ts
2import { createMiddleware } from 'hono/factory';
3import { HTTPException } from 'hono/http-exception';
4import type { AppEnv } from '../types/bindings';
5
6export 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 attempts
10};
11
12export 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');
16
17 const config = isAuthenticated
18 ? RATE_LIMIT_CONFIG.authenticated
19 : RATE_LIMIT_CONFIG.anonymous;
20
21 const limit = options?.limit ?? config.limit;
22 const rateLimiter = c.env.RATE_LIMITER;
23
24 if (!rateLimiter) {
25 // Rate limiting not configured - allow request
26 await next();
27 return;
28 }
29
30 try {
31 const key = `${clientId}:${c.req.path}`;
32 const { success } = await rateLimiter.limit({ key });
33
34 if (!success) {
35 throw new HTTPException(429, {
36 message: 'Too many requests. Please try again later.',
37 });
38 }
39
40 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}
48
49export 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.

typescript
1// src/middleware/rbac.ts
2import { createMiddleware } from 'hono/factory';
3import { HTTPException } from 'hono/http-exception';
4import type { AppEnv } from '../types/bindings';
5
6export const ROLE_HIERARCHY: Record<string, string[]> = {
7 admin: ['editor', 'viewer', 'user'],
8 editor: ['viewer', 'user'],
9 viewer: ['user'],
10 user: [],
11};
12
13export 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}
18
19export function requireRole(role: string | string[]) {
20 const roles = Array.isArray(role) ? role : [role];
21
22 return createMiddleware<AppEnv>(async (c, next) => {
23 const userRole = c.get('userRole');
24
25 if (!userRole) {
26 throw new HTTPException(401, { message: 'Authentication required' });
27 }
28
29 const hasRole = roles.some(r => roleHasPermission(userRole, r));
30
31 if (!hasRole) {
32 throw new HTTPException(403, {
33 message: `Access denied. Required role: ${roles.join(' or ')}`,
34 });
35 }
36
37 await next();
38 });
39}
40
41export function requirePermission(permission: string | string[]) {
42 const permissions = Array.isArray(permission) ? permission : [permission];
43
44 return createMiddleware<AppEnv>(async (c, next) => {
45 const userId = c.get('userId');
46 const userRole = c.get('userRole');
47
48 if (!userId || !userRole) {
49 throw new HTTPException(401, { message: 'Authentication required' });
50 }
51
52 // Check API key scopes first
53 const apiKeyScopes = c.get('apiKeyScopes');
54 if (apiKeyScopes?.every(p => permissions.includes(p))) {
55 await next();
56 return;
57 }
58
59 // Query user permissions from database
60 const { results } = await c.env.DB.prepare(`
61 SELECT DISTINCT p.name
62 FROM permissions p
63 JOIN role_permissions rp ON p.id = rp.permission_id
64 JOIN roles r ON rp.role_id = r.id
65 WHERE r.name = ?
66 `).bind(userRole).all();
67
68 const userPermissions = (results || []).map(r => r.name as string);
69 const hasPermission = permissions.every(p => userPermissions.includes(p));
70
71 if (!hasPermission) {
72 throw new HTTPException(403, {
73 message: `Access denied. Required permission: ${permissions.join(', ')}`,
74 });
75 }
76
77 await next();
78 });
79}
80
81export const adminOnly = () => requireRole('admin');
82export const editorOrAbove = () => requireRole(['admin', 'editor']);

Step 7: Audit Logging

Track all actions with non-blocking writes using waitUntil.

typescript
1// src/middleware/audit-logger.ts
2import { createMiddleware } from 'hono/factory';
3import type { Context } from 'hono';
4import type { AppEnv } from '../types/bindings';
5
6const SENSITIVE_FIELDS = ['password', 'token', 'secret', 'authorization'];
7
8function 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}
21
22export 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) : {};
37
38 // Non-blocking write using waitUntil
39 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}
55
56export function auditLogger(options: { resource: string; action?: string }) {
57 const { resource, action } = options;
58
59 return createMiddleware<AppEnv>(async (c, next) => {
60 const startTime = Date.now();
61 await next();
62
63 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();
70
71 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:

typescript
1// src/middleware/error-handler.ts
2import type { Context } from "hono";
3import { HTTPException } from "hono/http-exception";
4
5export function errorHandler(err: Error, c: Context) {
6 console.error(`[Error] ${err.message}`, err.stack);
7
8 // Handle Hono HTTP exceptions
9 if (err instanceof HTTPException) {
10 return c.json({ error: err.message }, err.status);
11 }
12
13 // Handle Zod validation errors
14 if (err.name === "ZodError") {
15 return c.json({
16 error: "Validation failed",
17 details: (err as unknown as { issues: unknown[] }).issues,
18 }, 400);
19 }
20
21 // Handle D1 database errors
22 if (err.message?.includes("D1")) {
23 return c.json({ error: "Database error" }, 503);
24 }
25
26 // Generic error - don't leak details in production
27 return c.json({ error: "Internal server error" }, 500);
28}
29
30export 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:

typescript
1// src/middleware/index.ts
2export { errorHandler, notFoundHandler } from "./error-handler";
3
4// Authentication
5export {
6 jwtAuth,
7 optionalJwtAuth,
8 generateAccessToken,
9 generateRefreshToken,
10 hashToken,
11 JWT_CONFIG,
12 type JWTPayload,
13} from "./jwt-auth";
14
15export {
16 apiKeyAuth,
17 combinedAuth,
18 generateApiKey,
19 hashApiKey,
20 extractKeyPrefix,
21 API_KEY_PREFIX,
22} from "./api-key-auth";
23
24// Rate Limiting
25export {
26 rateLimiter,
27 kvRateLimiter,
28 strictRateLimiter,
29 writeRateLimiter,
30 RATE_LIMIT_CONFIG,
31} from "./rate-limiter";
32
33// Authorization (RBAC)
34export {
35 requireRole,
36 requirePermission,
37 resourceOwner,
38 conditionalPermission,
39 adminOnly,
40 editorOrAbove,
41 roleHasPermission,
42 ROLE_HIERARCHY,
43} from "./rbac";
44
45// Audit Logging
46export {
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.

typescript
1// src/lib/password.ts
2const PBKDF2_ITERATIONS = 100000;
3const SALT_LENGTH = 16;
4const HASH_LENGTH = 256;
5
6export async function hashPassword(password: string): Promise<string> {
7 const encoder = new TextEncoder();
8 const salt = crypto.getRandomValues(new Uint8Array(SALT_LENGTH));
9
10 const keyMaterial = await crypto.subtle.importKey(
11 'raw',
12 encoder.encode(password),
13 'PBKDF2',
14 false,
15 ['deriveBits']
16 );
17
18 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_LENGTH
27 );
28
29 const combined = new Uint8Array(salt.length + new Uint8Array(hash).length);
30 combined.set(salt);
31 combined.set(new Uint8Array(hash), salt.length);
32
33 return btoa(String.fromCharCode(...combined));
34}
35
36export async function verifyPassword(
37 password: string,
38 storedHash: string
39): 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);
44
45 const encoder = new TextEncoder();
46 const keyMaterial = await crypto.subtle.importKey(
47 'raw',
48 encoder.encode(password),
49 'PBKDF2',
50 false,
51 ['deriveBits']
52 );
53
54 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_LENGTH
63 );
64
65 const newHash = new Uint8Array(hash);
66
67 // Constant-time comparison to prevent timing attacks
68 if (newHash.length !== originalHash.length) return false;
69
70 let result = 0;
71 for (let i = 0; i < newHash.length; i++) {
72 result |= newHash[i] ^ originalHash[i];
73 }
74
75 return result === 0;
76 } catch {
77 return false;
78 }
79}

Step 9: Auth Validation Schemas

Create Zod schemas for auth endpoint validation.

typescript
1// src/schemas/auth.ts
2import { z } from 'zod';
3
4const passwordSchema = z
5 .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');
10
11export const registerSchema = z.object({
12 email: z.string().email('Invalid email format'),
13 password: passwordSchema,
14});
15
16export const loginSchema = z.object({
17 email: z.string().email('Invalid email format'),
18 password: z.string().min(1, 'Password is required'),
19});
20
21export const refreshSchema = z.object({
22 refreshToken: z.string().min(1, 'Refresh token is required'),
23});
24
25export const changePasswordSchema = z.object({
26 currentPassword: z.string().min(1, 'Current password is required'),
27 newPassword: passwordSchema,
28});
29
30export 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:

typescript
1// src/schemas/post.ts
2import { z } from "zod";
3
4export 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});
9
10export const updatePostSchema = createPostSchema.partial();
11
12export 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});
17
18export type CreatePost = z.infer<typeof createPostSchema>;
19export type UpdatePost = z.infer<typeof updatePostSchema>;
20export type ListPostsQuery = z.infer<typeof listPostsQuerySchema>;

Schemas Barrel Export

typescript
1// src/schemas/index.ts
2export * from "./post";

Step 10: Auth Routes (Modular Structure)

Split auth routes into separate files for clean, maintainable code.

text
1src/routes/auth/
2├── index.ts # Combines all routes
3├── register.ts # POST /auth/register
4├── login.ts # POST /auth/login
5├── refresh.ts # POST /auth/refresh
6├── logout.ts # POST /auth/logout
7├── me.ts # GET /auth/me
8└── password.ts # PUT /auth/password

Register Route

typescript
1// src/routes/auth/register.ts
2import { Hono } from 'hono';
3import { zValidator } from '@hono/zod-validator';
4import { HTTPException } from 'hono/http-exception';
5
6import 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';
11
12const register = new Hono<AppEnv>();
13
14register.post('/', strictRateLimiter(), zValidator('json', registerSchema), async (c) => {
15 const { email, password } = c.req.valid('json');
16 const audit = createAuditLogger(c);
17
18 const existing = await c.env.DB.prepare('SELECT id FROM users WHERE email = ?')
19 .bind(email).first();
20
21 if (existing) {
22 throw new HTTPException(409, { message: 'User with this email already exists' });
23 }
24
25 const id = crypto.randomUUID();
26 const passwordHash = await hashPassword(password);
27
28 await c.env.DB.prepare(
29 'INSERT INTO users (id, email, password_hash, role) VALUES (?, ?, ?, ?)'
30 ).bind(id, email, passwordHash, 'user').run();
31
32 audit.log(AUDIT_ACTIONS.USER_CREATE, 'users', id, { email, role: 'user' });
33
34 return c.json({ id, email, message: 'User created successfully' }, 201);
35});
36
37export { register };

Login Route

typescript
1// src/routes/auth/login.ts
2import { Hono } from 'hono';
3import { zValidator } from '@hono/zod-validator';
4import { HTTPException } from 'hono/http-exception';
5
6import 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';
12
13const login = new Hono<AppEnv>();
14
15login.post('/', strictRateLimiter(), zValidator('json', loginSchema), async (c) => {
16 const { email, password } = c.req.valid('json');
17 const audit = createAuditLogger(c);
18
19 const user = await c.env.DB.prepare(
20 'SELECT id, email, password_hash, role, is_active FROM users WHERE email = ?'
21 ).bind(email).first();
22
23 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 }
27
28 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 }
32
33 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 }
38
39 const secret = c.env.JWT_SECRET;
40 if (!secret) {
41 throw new HTTPException(500, { message: 'Server configuration error' });
42 }
43
44 const accessToken = await generateAccessToken({
45 id: user.id as string,
46 email: user.email as string,
47 role: user.role as string,
48 }, secret);
49
50 const familyId = crypto.randomUUID();
51 const refreshToken = await generateRefreshToken(user.id as string, familyId, secret);
52
53 const refreshTokenHash = await hashToken(refreshToken);
54 const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString();
55
56 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();
59
60 audit.log(AUDIT_ACTIONS.LOGIN, 'auth', user.id as string, { email });
61
62 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});
70
71export { login };

Logout Route

typescript
1// src/routes/auth/logout.ts
2import { Hono } from 'hono';
3
4import type { AppEnv } from '../../types/bindings';
5import { jwtAuth } from '../../middleware/jwt-auth';
6import { createAuditLogger, AUDIT_ACTIONS } from '../../middleware/audit-logger';
7
8const logout = new Hono<AppEnv>();
9
10logout.post('/', jwtAuth(), async (c) => {
11 const userId = c.get('userId');
12 const audit = createAuditLogger(c);
13
14 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();
17
18 audit.log(AUDIT_ACTIONS.LOGOUT, 'auth', userId);
19
20 return c.json({ message: 'Logged out successfully' });
21});
22
23export { logout };

Me Route

typescript
1// src/routes/auth/me.ts
2import { Hono } from 'hono';
3import { HTTPException } from 'hono/http-exception';
4
5import type { AppEnv } from '../../types/bindings';
6import { jwtAuth } from '../../middleware/jwt-auth';
7
8const me = new Hono<AppEnv>();
9
10me.get('/', jwtAuth(), async (c) => {
11 const userId = c.get('userId');
12
13 const user = await c.env.DB.prepare(
14 'SELECT id, email, role, created_at FROM users WHERE id = ?'
15 ).bind(userId).first();
16
17 if (!user) {
18 throw new HTTPException(404, { message: 'User not found' });
19 }
20
21 return c.json(user);
22});
23
24export { me };

Password Route

typescript
1// src/routes/auth/password.ts
2import { Hono } from 'hono';
3import { zValidator } from '@hono/zod-validator';
4import { HTTPException } from 'hono/http-exception';
5
6import 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';
11
12const password = new Hono<AppEnv>();
13
14password.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);
18
19 const user = await c.env.DB.prepare(
20 'SELECT password_hash FROM users WHERE id = ?'
21 ).bind(userId).first();
22
23 if (!user) {
24 throw new HTTPException(404, { message: 'User not found' });
25 }
26
27 const isValid = await verifyPassword(currentPassword, user.password_hash as string);
28 if (!isValid) {
29 throw new HTTPException(401, { message: 'Current password is incorrect' });
30 }
31
32 const newHash = await hashPassword(newPassword);
33
34 await c.env.DB.prepare(
35 "UPDATE users SET password_hash = ?, updated_at = datetime('now') WHERE id = ?"
36 ).bind(newHash, userId).run();
37
38 await c.env.DB.prepare(
39 "UPDATE refresh_tokens SET revoked_at = datetime('now') WHERE user_id = ?"
40 ).bind(userId).run();
41
42 audit.log(AUDIT_ACTIONS.PASSWORD_CHANGE, 'auth', userId);
43
44 return c.json({ message: 'Password updated successfully' });
45});
46
47export { password };

Auth Index (Combines Routes)

typescript
1// src/routes/auth/index.ts
2import { Hono } from 'hono';
3import type { AppEnv } from '../../types/bindings';
4
5import { register } from './register';
6import { login } from './login';
7import { refresh } from './refresh';
8import { logout } from './logout';
9import { me } from './me';
10import { password } from './password';
11
12const auth = new Hono<AppEnv>();
13
14auth.route('/register', register);
15auth.route('/login', login);
16auth.route('/refresh', refresh);
17auth.route('/logout', logout);
18auth.route('/me', me);
19auth.route('/password', password);
20
21export { auth };

Refresh Route (Token Rotation)

typescript
1// src/routes/auth/refresh.ts
2import { Hono } from 'hono';
3import { zValidator } from '@hono/zod-validator';
4import { HTTPException } from 'hono/http-exception';
5import { verify } from 'hono/jwt';
6
7import 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';
12
13const refresh = new Hono<AppEnv>();
14
15refresh.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;
19
20 // Verify token
21 let payload;
22 try {
23 payload = await verify(refreshToken, secret, 'HS256');
24 } catch {
25 throw new HTTPException(401, { message: 'Invalid refresh token' });
26 }
27
28 if ((payload as { type?: string }).type !== 'refresh') {
29 throw new HTTPException(401, { message: 'Invalid token type' });
30 }
31
32 // Find token in database
33 const tokenHash = await hashToken(refreshToken);
34 const storedToken = await c.env.DB.prepare(`
35 SELECT rt.*, u.email, u.role, u.is_active
36 FROM refresh_tokens rt
37 JOIN users u ON rt.user_id = u.id
38 WHERE rt.token_hash = ? AND rt.revoked_at IS NULL AND rt.expires_at > datetime('now')
39 `).bind(tokenHash).first();
40
41 if (!storedToken) {
42 // Possible token reuse attack - revoke entire family
43 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();
47
48 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 }
55
56 if (!storedToken.is_active) {
57 throw new HTTPException(403, { message: 'Account is disabled' });
58 }
59
60 // Revoke old token
61 await c.env.DB.prepare(
62 "UPDATE refresh_tokens SET revoked_at = datetime('now') WHERE id = ?"
63 ).bind(storedToken.id).run();
64
65 // 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);
71
72 const newRefreshToken = await generateRefreshToken(
73 storedToken.user_id as string,
74 storedToken.family_id as string,
75 secret
76 );
77
78 // Store new token
79 const newTokenHash = await hashToken(newRefreshToken);
80 const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString();
81
82 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();
85
86 audit.log(AUDIT_ACTIONS.TOKEN_REFRESH, 'auth', storedToken.user_id as string);
87
88 return c.json({
89 accessToken,
90 refreshToken: newRefreshToken,
91 tokenType: 'Bearer',
92 expiresIn: 900,
93 });
94});
95
96export { refresh };

Step 10b: Posts Route

CRUD operations for posts:

typescript
1// src/routes/posts.ts
2import { 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';
7
8const posts = new Hono<AppEnv>();
9
10// GET /posts - List all posts with pagination
11posts.get('/', zValidator('query', listPostsQuerySchema), async (c) => {
12 const { page, limit, published } = c.req.valid('query');
13 const offset = (page - 1) * limit;
14
15 let query = 'SELECT * FROM posts';
16 const params: (string | number)[] = [];
17
18 if (published !== undefined) {
19 query += ' WHERE published = ?';
20 params.push(published === 'true' ? 1 : 0);
21 }
22
23 query += ' ORDER BY created_at DESC LIMIT ? OFFSET ?';
24 params.push(limit, offset);
25
26 const { results } = await c.env.DB.prepare(query).bind(...params).all();
27 return c.json({ posts: results, page, limit });
28});
29
30// GET /posts/:id - Get single post
31posts.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();
34
35 if (!post) {
36 throw new HTTPException(404, { message: 'Post not found' });
37 }
38 return c.json(post);
39});
40
41// POST /posts - Create new post
42posts.post('/', zValidator('json', createPostSchema), async (c) => {
43 const data = c.req.valid('json');
44 const id = crypto.randomUUID();
45
46 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();
49
50 return c.json({ id, ...data }, 201);
51});
52
53// PUT /posts/:id - Update post
54posts.put('/:id', zValidator('json', updatePostSchema), async (c) => {
55 const id = c.req.param('id');
56 const data = c.req.valid('json');
57
58 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 }
62
63 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 id
70 ).run();
71
72 return c.json({ id, ...data });
73});
74
75// DELETE /posts/:id - Delete post
76posts.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();
79
80 if (result.meta.changes === 0) {
81 throw new HTTPException(404, { message: 'Post not found' });
82 }
83 return c.json({ deleted: true });
84});
85
86export { posts };

Step 10c: API Keys Route

Manage API keys for server-to-server authentication:

typescript
1// src/routes/api-keys.ts
2import { 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';
11
12const apiKeys = new Hono<AppEnv>();
13
14// All routes require JWT authentication
15apiKeys.use('*', jwtAuth());
16
17const 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});
22
23// GET /api-keys - List user's API keys
24apiKeys.get('/', async (c) => {
25 const userId = c.get('userId');
26
27 const { results } = await c.env.DB.prepare(`
28 SELECT id, key_prefix, name, scopes, rate_limit, last_used_at, expires_at, created_at
29 FROM api_keys WHERE user_id = ? AND revoked_at IS NULL ORDER BY created_at DESC
30 `).bind(userId).all();
31
32 const keys = (results || []).map((key) => ({
33 ...key,
34 scopes: JSON.parse((key.scopes as string) || '[]'),
35 }));
36
37 return c.json({ apiKeys: keys, page: 1, limit: 20 });
38});
39
40// POST /api-keys - Create new API key
41apiKeys.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);
45
46 const { key, prefix } = await generateApiKey('secret', true);
47 const keyHash = await hashApiKey(key);
48 const id = crypto.randomUUID();
49
50 const expiresAt = expiresInDays
51 ? new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000).toISOString()
52 : null;
53
54 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();
58
59 audit.log(AUDIT_ACTIONS.API_KEY_CREATE, 'api_keys', id, { name, scopes });
60
61 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});
71
72// DELETE /api-keys/:id - Revoke API key
73apiKeys.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);
78
79 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();
82
83 if (!existing) {
84 throw new HTTPException(404, { message: 'API key not found' });
85 }
86
87 if (existing.user_id !== userId && userRole !== 'admin') {
88 throw new HTTPException(403, { message: 'You can only revoke your own API keys' });
89 }
90
91 await c.env.DB.prepare(
92 "UPDATE api_keys SET revoked_at = datetime('now') WHERE id = ?"
93 ).bind(keyId).run();
94
95 audit.log(AUDIT_ACTIONS.API_KEY_REVOKE, 'api_keys', keyId);
96
97 return c.json({ message: 'API key revoked', id: keyId });
98});
99
100// GET /api-keys/admin/all - Admin only: list all keys
101apiKeys.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_email
104 FROM api_keys ak JOIN users u ON ak.user_id = u.id
105 WHERE ak.revoked_at IS NULL ORDER BY ak.created_at DESC LIMIT 100
106 `).all();
107
108 const keys = (results || []).map((key) => ({
109 ...key,
110 scopes: JSON.parse((key.scopes as string) || '[]'),
111 }));
112
113 return c.json({ apiKeys: keys });
114});
115
116export { apiKeys };

Step 10d: Routes Barrel Export

Combine all route exports:

typescript
1// src/routes/index.ts
2export { posts } from './posts';
3export { auth } from './auth';
4export { apiKeys } from './api-keys';

Step 11: Wire Everything Together

Update your main app entry point.

typescript
1// src/index.ts
2import { Hono } from 'hono';
3import { cors } from 'hono/cors';
4import { logger } from 'hono/logger';
5import { secureHeaders } from 'hono/secure-headers';
6
7import 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';
16
17const app = new Hono<AppEnv>();
18
19// Request ID
20app.use('*', async (c, next) => {
21 c.set('requestId', crypto.randomUUID());
22 await next();
23});
24
25// Global middleware
26app.use('*', logger());
27app.use('*', secureHeaders({
28 contentSecurityPolicy: {
29 defaultSrc: ["'none'"],
30 frameAncestors: ["'none'"],
31 },
32 xFrameOptions: 'DENY',
33}));
34
35// CORS
36app.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}));
42
43// Rate limiting
44app.use('/api/*', rateLimiter());
45
46// Health check
47app.get('/health', (c) => c.json({ status: 'ok', requestId: c.get('requestId') }));
48
49// Auth routes (public)
50app.route('/api/auth', auth);
51
52// Posts - protected write operations
53const 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));
59
60app.route('/api/posts', protectedPosts);
61app.route('/api/posts', posts); // Public GET
62
63// API Keys management
64app.route('/api/api-keys', apiKeys);
65
66// Error handling
67app.onError(errorHandler);
68app.notFound(notFoundHandler);
69
70export default app;

Step 12: Environment Configuration

Configure secrets and environment variables for development and production.

Generate a Secure Secret

Generate JWT Secret
$openssl rand -base64 32
K7gNU3sdo+OL0wNhqoVWhr3g6s1xYv72ol/pe/Unols=
1 commandbuun.group

Local Development (.dev.vars)

First, ensure .dev.vars is in your .gitignore:

bash
1# .gitignore
2.dev.vars
3.wrangler
4node_modules
5dist

Copy the example file and add your secret:

Setup Development Secrets
# Copy the example file
$cp .dev.vars.example .dev.vars
$# Edit .dev.vars and add your generated JWT_SECRET
2 commandsbuun.group
bash
1# .dev.vars (gitignored - never commit this file!)
2
3# JWT Secret for signing tokens (minimum 32 characters)
4JWT_SECRET=your-super-secret-jwt-key-min-32-chars-here

Production (Wrangler Secrets)

For production, use Cloudflare's encrypted secrets storage:

Set Production Secret
# Paste your generated secret when prompted
$wrangler secret put JWT_SECRET
$wrangler secret list
JWT_SECRET
2 commandsbuun.group

wrangler.toml Configuration

toml
1# wrangler.toml
2name = "hono-api"
3main = "src/index.ts"
4compatibility_date = "2024-12-01"
5
6# D1 Database
7[[d1_databases]]
8binding = "DB"
9database_name = "my-database"
10database_id = "your-database-id-here"
11
12# Rate Limiting (Cloudflare Pro+)
13# [[unsafe.bindings]]
14# name = "RATE_LIMITER"
15# type = "ratelimit"
16# namespace_id = "1001"
17# simple = { limit = 100, period = 60 }
18
19# Environment Variables (non-secret)
20[vars]
21ENVIRONMENT = "production"
22
23# Secret Variables (set via wrangler secret put)
24# JWT_SECRET - Run: wrangler secret put JWT_SECRET
25
26# Development Settings
27[dev]
28port = 5138
29local_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:

typescript
1// vitest.config.ts
2import { defineWorkersConfig } from "@cloudflare/vitest-pool-workers/config";
3
4export 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 tests
13 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

text
1tests/
2├── setup.ts # Global test setup
3├── cloudflare-test.d.ts # Type definitions
4├── helpers/
5│ └── auth.ts # Shared test utilities
6├── routes/
7│ ├── auth/
8│ │ ├── register.test.ts # POST /auth/register
9│ │ ├── login.test.ts # POST /auth/login
10│ │ ├── logout.test.ts # POST /auth/logout
11│ │ └── me.test.ts # GET /auth/me
12│ ├── posts/
13│ │ └── posts.test.ts # Posts CRUD tests
14│ └── api-keys/
15│ └── api-keys.test.ts # API key management
16└── integration/
17 └── health.test.ts # Health check & 404

Test Type Definitions

First, extend the Cloudflare test types with your bindings:

typescript
1// tests/cloudflare-test.d.ts
2import type { Bindings } from "../src/types/bindings";
3
4declare module "cloudflare:test" {
5 // eslint-disable-next-line @typescript-eslint/no-empty-object-type
6 interface ProvidedEnv extends Bindings {}
7}

Test Setup (Database Schema)

Create tables and seed data before tests run:

typescript
1// tests/setup.ts
2import { env } from "cloudflare:test";
3import { beforeAll } from "vitest";
4
5beforeAll(async () => {
6 // Users table
7 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_TIMESTAMP
16 )
17 `);
18
19 // Refresh tokens table
20 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 NULL
29 )
30 `);
31
32 // API keys table
33 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 NULL
46 )
47 `);
48
49 // Posts table
50 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_TIMESTAMP
58 )
59 `);
60
61 // Audit logs table
62 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_TIMESTAMP
73 )
74 `);
75
76 // Roles & permissions tables
77 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))`);
80
81 // Seed default roles
82 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')");
85
86 // Seed default permissions
87 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:

typescript
1// tests/helpers/auth.ts
2import { env } from "cloudflare:test";
3import app from "../../src/index";
4
5export const headers = {
6 "Content-Type": "application/json",
7 Origin: "http://localhost",
8};
9
10export type LoginResponse = {
11 accessToken: string;
12 refreshToken: string;
13 user: { id: string; email: string; role: string };
14};
15
16export const uniqueEmail = () =>
17 `test-${Date.now()}-${Math.random().toString(36).slice(2)}@example.com`;
18
19export async function getAuthToken(): Promise<string> {
20 const email = uniqueEmail();
21
22 await app.request("/api/auth/register", {
23 method: "POST",
24 headers,
25 body: JSON.stringify({ email, password: "SecurePass123" }),
26 }, env);
27
28 const loginRes = await app.request("/api/auth/login", {
29 method: "POST",
30 headers,
31 body: JSON.stringify({ email, password: "SecurePass123" }),
32 }, env);
33
34 const data = (await loginRes.json()) as LoginResponse;
35 return data.accessToken;
36}

Example: Auth Route Tests

typescript
1// tests/routes/auth/register.test.ts
2import { describe, it, expect } from "vitest";
3import { env } from "cloudflare:test";
4import app from "../../../src/index";
5import { headers, uniqueEmail } from "../../helpers/auth";
6
7describe("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);
15
16 expect(res.status).toBe(201);
17 const data = await res.json();
18 expect(data.email).toBe(email);
19 expect(data.id).toBeDefined();
20 });
21
22 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);
28
29 expect(res.status).toBe(400);
30 });
31
32 it("rejects duplicate email", async () => {
33 const email = uniqueEmail();
34
35 // Register first time
36 await app.request("/api/auth/register", {
37 method: "POST",
38 headers,
39 body: JSON.stringify({ email, password: "SecurePass123" }),
40 }, env);
41
42 // Try again - should fail
43 const res = await app.request("/api/auth/register", {
44 method: "POST",
45 headers,
46 body: JSON.stringify({ email, password: "SecurePass123" }),
47 }, env);
48
49 expect(res.status).toBe(409);
50 });
51});

Example: Protected Route Tests

typescript
1// tests/routes/auth/me.test.ts
2import { describe, it, expect } from "vitest";
3import { env } from "cloudflare:test";
4import app from "../../../src/index";
5import { headers, getAuthToken } from "../../helpers/auth";
6
7describe("Auth - Me", () => {
8 it("returns user info with valid token", async () => {
9 const token = await getAuthToken();
10
11 const res = await app.request("/api/auth/me", {
12 headers: { ...headers, Authorization: `Bearer ${token}` },
13 }, env);
14
15 expect(res.status).toBe(200);
16 const data = await res.json();
17 expect(data.email).toBeDefined();
18 expect(data.role).toBe("user");
19 });
20
21 it("rejects missing token", async () => {
22 const res = await app.request("/api/auth/me", { headers }, env);
23 expect(res.status).toBe(401);
24 });
25
26 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

Run Test Suite
$npm test
Test Files 7 passed (7) Tests 26 passed (26)
# Detailed output
$npm test -- --reporter=verbose
2 commandsbuun.group

Manual Testing with cURL

terminal
${
$"title": "Test Authentication Flow",
$"commands": [
${ "command": "curl -X POST http://localhost:5138/api/auth/register -H 'Content-Type: application/json' -d '{"email":"test@example.com","password":"SecurePass123"}' | jq", "output": "{ "id": "...", "email": "test@example.com" }", "success": true },
${ "command": "curl -X POST http://localhost:5138/api/auth/login -H 'Content-Type: application/json' -d '{"email":"test@example.com","password":"SecurePass123"}' | jq", "output": "{ "accessToken": "...", "refreshToken": "..." }", "success": true },
${ "command": "curl http://localhost:5138/api/auth/me -H 'Authorization: Bearer <token>' | jq", "output": "{ "id": "...", "email": "..." }", "success": true }
$]
$}
8 commandsbuun.group

Best Practices Recap

Production Security Checklist
0/6
0% completebuun.group

Continue the Series

Hono API Tutorial Series
Part 2 of 2
2

You are here

Secure Your API

Tutorial Seriesbuun.group

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

Hono securityJWT authenticationAPI key authenticationrate limitingRBACrole-based access controlCloudflare WorkersAPI security

Share this post

Share

Comments

Sign in to join the conversation

Login

No comments yet. Be the first to share your thoughts!

Found an issue with this article?

/ Let's Talk

Want to work with us?

Whether you need help with architecture, development, or technical consulting, our team is here to help bring your vision to life.