SonicJS AI implements a comprehensive authentication and authorization system using JWT tokens, KV-based caching, and role-based access control (RBAC). This guide covers all aspects of user authentication, security, and permissions.
- Overview
- Authentication Flow
- JWT Implementation
- Token Caching with KV
- Password Security
- Role-Based Access Control
- Permission System
- Auth Routes & Endpoints
- Session Management
- User Invitation System
- Password Reset Flow
- Implementing Authentication in Routes
- Security Best Practices
- Troubleshooting
SonicJS AI uses a modern authentication architecture built on:
- JWT (JSON Web Tokens) for stateless authentication
- Cloudflare KV for token verification caching (5-minute TTL)
- SHA-256 password hashing with salt
- RBAC (Role-Based Access Control) for fine-grained permissions
- HTTP-only cookies and Bearer token support
- Session tracking with activity logging
- Invitation-based user onboarding
Endpoint: POST /auth/register
// Request
{
"email": "user@example.com",
"password": "securePassword123",
"username": "johndoe",
"firstName": "John",
"lastName": "Doe"
}
// Response
{
"user": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"email": "user@example.com",
"username": "johndoe",
"firstName": "John",
"lastName": "Doe",
"role": "viewer"
},
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}Process:
- Email normalized to lowercase
- Check for duplicate email/username
- Password hashed with SHA-256 + salt
- User created with default role:
viewer - JWT token generated (TTL from
JWT_EXPIRES_IN, default 30 days) - HTTP-only cookie set
- Token returned in response
Endpoint: POST /auth/login
// Request
{
"email": "user@example.com",
"password": "securePassword123"
}
// Response
{
"user": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"email": "user@example.com",
"username": "johndoe",
"firstName": "John",
"lastName": "Doe",
"role": "viewer"
},
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}Process:
- Email normalized to lowercase
- User lookup with KV caching
- Password verification with SHA-256
- JWT token generation
- HTTP-only cookie set (TTL from
JWT_EXPIRES_IN, default 30 days) last_login_attimestamp updated- User cache invalidated to ensure fresh data
Endpoint: POST /auth/refresh
// Headers (or cookie)
Authorization: Bearer <current_or_recently_expired_token>
// Response
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"expiresIn": 2592000
}Accepts a JWT that is either still valid or has expired within the grace
window (JWT_REFRESH_GRACE_SECONDS, default 7 days). Re-validates the user
in the database and issues a freshly-signed token — no password/OTP required.
This supports sliding-session auth so a long-lived session cookie can keep
a user logged in across JWT rotations.
SonicJS AI uses JWTs with the following payload:
interface JWTPayload {
userId: string; // User's unique ID
email: string; // User's email (normalized)
role: string; // User's role (admin, editor, viewer)
exp: number; // Expiration timestamp (Unix)
iat: number; // Issued at timestamp (Unix)
}import { AuthManager, getJwtExpirySeconds } from '../middleware/auth'
// Generate a token with the environment-configured TTL
const ttl = getJwtExpirySeconds(env)
const token = await AuthManager.generateToken(
userId,
email,
role,
env.JWT_SECRET,
ttl
)JWT TTL is controlled by the JWT_EXPIRES_IN environment variable. It accepts
a plain number of seconds or a duration string:
| Example value | Meaning |
|---|---|
2592000 |
30 days (default) |
30d |
30 days |
12h |
12 hours |
3600s |
1 hour |
If JWT_EXPIRES_IN is not set, SonicJS defaults to 30 days.
The refresh endpoint also honors JWT_REFRESH_GRACE_SECONDS (default 7 days),
which controls how long after expiration a token can still be used to obtain a
fresh one via POST /auth/refresh.
The JWT_SECRET lives on the Cloudflare Workers binding (c.env.JWT_SECRET), so
you must thread the secret through when verifying tokens. The easiest way from a
Hono handler is AuthManager.verifyAuthRequest(c), which extracts the token
from the Authorization header (or auth_token cookie) and pulls the secret
from c.env for you:
// Inside a custom Hono route handler
const payload = await AuthManager.verifyAuthRequest(c)
if (!payload) {
return c.json({ error: 'Invalid or expired token' }, 401)
}
console.log(payload.userId, payload.email, payload.role)If you already have the raw token, call verifyToken directly and pass the
secret yourself:
const payload = await AuthManager.verifyToken(token, c.env.JWT_SECRET)Heads up:
AuthManager.verifyToken(token)(no secret argument) falls back to a development-only placeholder secret. In production this will silently fail to verify any real token. Always passc.env.JWT_SECRET, or useverifyAuthRequest(c)/ therequireAuth()middleware.
// Default configuration in src/middleware/auth.ts
const JWT_SECRET = 'your-super-secret-jwt-key-change-in-production'
// Token expiration: 24 hours
const TOKEN_EXPIRY = 60 * 60 * 24Production Configuration:
# Set JWT_SECRET in wrangler.toml or Cloudflare dashboard
[vars]
JWT_SECRET = "your-256-bit-production-secret"SonicJS AI implements intelligent token verification caching using Cloudflare KV to reduce JWT verification overhead.
// In requireAuth() middleware
export const requireAuth = () => {
return async (c: Context, next: Next) => {
// Get token from header or cookie
let token = c.req.header('Authorization')?.replace('Bearer ', '')
if (!token) {
token = getCookie(c, 'auth_token')
}
// Try to get cached token verification from KV
const kv = c.env?.KV
let payload: JWTPayload | null = null
if (kv) {
const cacheKey = `auth:${token.substring(0, 20)}`
const cached = await kv.get(cacheKey, 'json')
if (cached) {
payload = cached as JWTPayload
}
}
// If not cached, verify token (passing the JWT_SECRET binding)
if (!payload) {
payload = await AuthManager.verifyToken(token, c.env?.JWT_SECRET)
// Cache the verified payload for 5 minutes
if (payload && kv) {
const cacheKey = `auth:${token.substring(0, 20)}`
await kv.put(cacheKey, JSON.stringify(payload), {
expirationTtl: 300 // 5 minutes
})
}
}
if (!payload) {
return c.json({ error: 'Invalid or expired token' }, 401)
}
// Add user info to context
c.set('user', payload)
await next()
}
}- Cache Key:
auth:{first-20-chars-of-token} - TTL: 5 minutes (300 seconds)
- Cache Miss: Verifies JWT and caches result
- Cache Hit: Returns cached payload (faster)
- Invalidation: Automatic after 5 minutes
- Reduces JWT signature verification overhead
- Faster response times for authenticated requests
- Scales better under high load
- Cloudflare KV provides global edge caching
SonicJS AI uses SHA-256 with a salt for password hashing:
export class AuthManager {
static async hashPassword(password: string): Promise<string> {
// In Cloudflare Workers, we use Web Crypto API
const encoder = new TextEncoder()
const data = encoder.encode(password + 'salt-change-in-production')
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
const hashArray = Array.from(new Uint8Array(hashBuffer))
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
}
static async verifyPassword(password: string, hash: string): Promise<boolean> {
const passwordHash = await this.hashPassword(password)
return passwordHash === hash
}
}- Change the salt in production - Update
'salt-change-in-production'to a unique, secure value - SHA-256 vs bcrypt - SHA-256 is used because bcrypt is not natively available in Cloudflare Workers
- Salt storage - The salt is currently hardcoded; consider using environment variables
- Password requirements - Minimum 8 characters (enforced in validation schemas)
// Registration schema
const registerSchema = z.object({
email: z.string().email('Valid email is required'),
password: z.string().min(8, 'Password must be at least 8 characters'),
username: z.string().min(3, 'Username must be at least 3 characters'),
firstName: z.string().min(1, 'First name is required'),
lastName: z.string().min(1, 'Last name is required')
})Passwords are tracked in the password_history table for security:
CREATE TABLE password_history (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
password_hash TEXT NOT NULL,
created_at INTEGER NOT NULL
);| Role | Description | Typical Use Case |
|---|---|---|
admin |
Full system access | System administrators |
editor |
Content management | Content managers and editors |
viewer |
Read-only access | Basic users, guests |
Note: The author role exists in team contexts but not as a global role.
| Permission Category | Admin | Editor | Viewer |
|---|---|---|---|
| Content | |||
| Create content | ✅ | ✅ | ❌ |
| Read content | ✅ | ✅ | ✅ |
| Update content | ✅ | ✅ | ❌ |
| Delete content | ✅ | ❌ | ❌ |
| Publish content | ✅ | ✅ | ❌ |
| Collections | |||
| Create collections | ✅ | ❌ | ❌ |
| Read collections | ✅ | ✅ | ✅ |
| Update collections | ✅ | ❌ | ❌ |
| Delete collections | ✅ | ❌ | ❌ |
| Manage fields | ✅ | ❌ | ❌ |
| Media | |||
| Upload media | ✅ | ✅ | ❌ |
| Read media | ✅ | ✅ | ✅ |
| Update media | ✅ | ✅ | ❌ |
| Delete media | ✅ | ❌ | ❌ |
| Users | |||
| Create/invite users | ✅ | ❌ | ❌ |
| Read users | ✅ | ✅ | ✅ |
| Update users | ✅ | ❌ | ❌ |
| Delete users | ✅ | ❌ | ❌ |
| Manage roles | ✅ | ❌ | ❌ |
| Settings | |||
| Read settings | ✅ | ❌ | ❌ |
| Update settings | ✅ | ❌ | ❌ |
| View activity logs | ✅ | ❌ | ❌ |
import { requireAuth, requireRole } from '../middleware/auth'
// Require authentication only
app.get('/protected', requireAuth(), (c) => {
const user = c.get('user')
return c.json({ message: 'Authenticated', user })
})
// Require specific role (single)
app.delete('/admin/users/:id',
requireAuth(),
requireRole('admin'),
(c) => {
// Admin-only endpoint
}
)
// Require one of multiple roles
app.post('/content',
requireAuth(),
requireRole(['admin', 'editor']),
(c) => {
// Admin or editor can create content
}
)Implementation:
export const requireRole = (requiredRole: string | string[]) => {
return async (c: Context, next: Next) => {
const user = c.get('user') as JWTPayload
if (!user) {
return c.json({ error: 'Authentication required' }, 401)
}
const roles = Array.isArray(requiredRole) ? requiredRole : [requiredRole]
if (!roles.includes(user.role)) {
return c.json({ error: 'Insufficient permissions' }, 403)
}
return await next()
}
}SonicJS AI implements a granular permission system on top of RBAC.
export interface Permission {
id: string; // e.g., 'perm_content_create'
name: string; // e.g., 'content.create'
description: string; // Human-readable description
category: string; // content, users, collections, media, settings
}
export interface UserPermissions {
userId: string;
role: string;
permissions: string[]; // Global permissions
teamPermissions?: Record<string, string[]>; // Team-specific permissions
}Content Permissions:
content.create- Create new contentcontent.read- View contentcontent.update- Edit existing contentcontent.delete- Delete contentcontent.publish- Publish/unpublish content
Collections Permissions:
collections.create- Create new collectionscollections.read- View collectionscollections.update- Edit collectionscollections.delete- Delete collectionscollections.fields- Manage collection fields
Media Permissions:
media.upload- Upload media filesmedia.read- View media filesmedia.update- Edit media metadatamedia.delete- Delete media files
Users Permissions:
users.create- Invite new usersusers.read- View user profilesusers.update- Edit user profilesusers.delete- Deactivate usersusers.roles- Manage user roles
Settings Permissions:
settings.read- View system settingssettings.update- Modify system settingsactivity.read- View activity logs
import { PermissionManager } from '../middleware/permissions'
// Check if user has permission
const canEdit = await PermissionManager.hasPermission(
db,
userId,
'content.update'
)
if (!canEdit) {
return c.json({ error: 'Permission denied' }, 403)
}
// Check multiple permissions at once
const permissions = await PermissionManager.checkMultiplePermissions(
db,
userId,
['content.create', 'content.publish']
)
console.log(permissions)
// { 'content.create': true, 'content.publish': false }import { requirePermission, requireAnyPermission } from '../middleware/permissions'
// Require specific permission
app.delete('/content/:id',
requireAuth(),
requirePermission('content.delete'),
async (c) => {
// User has content.delete permission
}
)
// Require any of multiple permissions
app.post('/content/:id/publish',
requireAuth(),
requireAnyPermission(['content.publish', 'content.update']),
async (c) => {
// User has either content.publish OR content.update
}
)// Check team-specific permission
const canEditInTeam = await PermissionManager.hasPermission(
db,
userId,
'content.update',
teamId // Optional team context
)
// Middleware with team context
app.put('/teams/:teamId/content/:contentId',
requireAuth(),
requirePermission('content.update', 'teamId'),
async (c) => {
// User has content.update permission in this specific team
}
)The PermissionManager implements in-memory caching:
export class PermissionManager {
private static permissionCache = new Map<string, UserPermissions>()
private static cacheExpiry = new Map<string, number>()
private static CACHE_TTL = 5 * 60 * 1000 // 5 minutes
static async getUserPermissions(db: D1Database, userId: string): Promise<UserPermissions> {
const cacheKey = `permissions:${userId}`
const now = Date.now()
// Check cache
if (this.permissionCache.has(cacheKey)) {
const expiry = this.cacheExpiry.get(cacheKey) || 0
if (now < expiry) {
return this.permissionCache.get(cacheKey)!
}
}
// Fetch from database and cache...
}
// Clear cache when permissions change
static clearUserCache(userId: string) {
const cacheKey = `permissions:${userId}`
this.permissionCache.delete(cacheKey)
this.cacheExpiry.delete(cacheKey)
}
}GET /auth/login
Renders the login HTML form. Supports query parameters:
?error=<message>- Display error message?message=<message>- Display info message
GET /auth/register
Renders the registration HTML form.
POST /auth/login
// Request body
{
"email": "user@example.com",
"password": "password123"
}
// Success response (200)
{
"user": {
"id": "uuid",
"email": "user@example.com",
"username": "username",
"firstName": "John",
"lastName": "Doe",
"role": "viewer"
},
"token": "jwt-token"
}
// Error response (401)
{
"error": "Invalid email or password"
}POST /auth/login/form
Handles HTML form submissions. Returns HTMX-compatible HTML response.
POST /auth/register
// Request body
{
"email": "user@example.com",
"password": "password123",
"username": "johndoe",
"firstName": "John",
"lastName": "Doe"
}
// Success response (201)
{
"user": {
"id": "uuid",
"email": "user@example.com",
"username": "johndoe",
"firstName": "John",
"lastName": "Doe",
"role": "viewer"
},
"token": "jwt-token"
}
// Error response (400)
{
"error": "User with this email or username already exists"
}POST /auth/register/form
Handles HTML form submissions. First user registered gets admin role.
GET /auth/logout or POST /auth/logout
Clears the auth_token cookie and redirects to login page.
// GET response
// Redirects to /auth/login?message=You have been logged out successfully
// POST response (200)
{
"message": "Logged out successfully"
}GET /auth/me
Requires authentication.
// Headers
Authorization: Bearer <token>
// Response (200)
{
"user": {
"id": "uuid",
"email": "user@example.com",
"username": "johndoe",
"first_name": "John",
"last_name": "Doe",
"role": "viewer",
"created_at": 1234567890000
}
}POST /auth/refresh
Requires authentication. Generates a new token with extended expiration.
// Headers
Authorization: Bearer <current-token>
// Response (200)
{
"token": "new-jwt-token"
}POST /auth/seed-admin
Creates default admin user for testing. Not for production use.
// Response (200)
{
"message": "Admin user created successfully",
"user": {
"id": "admin-user-id",
"email": "admin@sonicjs.com",
"username": "admin",
"role": "admin"
}
}
// Default credentials
// Email: admin@sonicjs.com
// Password: sonicjs!SonicJS AI uses secure, HTTP-only cookies for session management:
setCookie(c, 'auth_token', token, {
httpOnly: true, // Cannot be accessed via JavaScript
secure: true, // HTTPS only in production
sameSite: 'Strict', // CSRF protection
maxAge: 60 * 60 * 24 // 24 hours
})Sessions are tracked in the user_sessions table:
CREATE TABLE user_sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL,
ip_address TEXT,
user_agent TEXT,
is_active INTEGER NOT NULL DEFAULT 1,
expires_at INTEGER NOT NULL,
created_at INTEGER NOT NULL,
last_used_at INTEGER
);The requireAuth() middleware supports multiple token sources:
// 1. Authorization header (Bearer token)
Authorization: Bearer <token>
// 2. HTTP-only cookie
Cookie: auth_token=<token>
// Priority: Header > Cookie- Token expiration: 24 hours from issue time
- Cookie expiration: 24 hours (maxAge)
- Cache expiration: 5 minutes (KV TTL)
When a token expires:
- JWT verification fails
- User redirected to login (HTML requests)
- 401 error returned (API requests)
POST /admin/users/invite (admin-only)
{
"email": "newuser@example.com",
"firstName": "Jane",
"lastName": "Smith",
"role": "editor"
}Process:
- Admin creates user record with
is_active = 0 - Unique
invitation_tokengenerated - Invitation email sent (or link returned for dev)
- User account inactive until accepted
GET /auth/accept-invitation?token=<invitation-token>
Displays invitation acceptance form with:
- Pre-filled user details (name, email, role)
- Username input
- Password input
- Confirm password input
POST /auth/accept-invitation
// Form data
{
"token": "invitation-token",
"username": "janesmith",
"password": "securePassword123",
"confirm_password": "securePassword123"
}Process:
- Validate invitation token
- Check token expiration (7 days)
- Verify username availability
- Hash password
- Activate user (
is_active = 1) - Clear
invitation_token - Auto-login with JWT token
- Redirect to admin dashboard
Invitations expire after 7 days:
const invitationAge = Date.now() - invitedUser.invited_at
const maxAge = 7 * 24 * 60 * 60 * 1000 // 7 days
if (invitationAge > maxAge) {
return c.json({ error: 'Invitation has expired' }, 400)
}POST /auth/request-password-reset
// Form data
{
"email": "user@example.com"
}
// Response (always success to prevent email enumeration)
{
"success": true,
"message": "If an account with this email exists, a password reset link has been sent.",
"reset_link": "http://localhost:8787/auth/reset-password?token=..." // Dev only
}Process:
- Normalize email to lowercase
- Look up user (returns success even if not found)
- Generate unique
password_reset_token - Set expiration: 1 hour
- Update user record
- Send reset email (or return link in dev)
- Log activity
GET /auth/reset-password?token=<reset-token>
Displays password reset form if token is valid and not expired.
POST /auth/reset-password
// Form data
{
"token": "reset-token",
"password": "newPassword123",
"confirm_password": "newPassword123"
}Process:
- Validate reset token
- Check expiration (1 hour)
- Verify passwords match
- Hash new password
- Store old password in
password_history - Update user with new password
- Clear reset token
- Log activity
- Redirect to login
Reset tokens expire after 1 hour:
const resetExpires = Date.now() + (60 * 60 * 1000) // 1 hour
if (Date.now() > user.password_reset_expires) {
return c.json({ error: 'Reset token has expired' }, 400)
}import { Hono } from 'hono'
import { requireAuth } from '../middleware/auth'
const app = new Hono()
// Public route
app.get('/public', (c) => {
return c.json({ message: 'Public access' })
})
// Protected route
app.get('/protected', requireAuth(), (c) => {
const user = c.get('user')
return c.json({
message: 'Authenticated access',
userId: user.userId,
email: user.email,
role: user.role
})
})import { requireAuth, requireRole } from '../middleware/auth'
// Admin only
app.delete('/admin/users/:id',
requireAuth(),
requireRole('admin'),
async (c) => {
const userId = c.req.param('id')
// Delete user logic
return c.json({ message: 'User deleted' })
}
)
// Editor or Admin
app.post('/content',
requireAuth(),
requireRole(['admin', 'editor']),
async (c) => {
const data = await c.req.json()
// Create content logic
return c.json({ message: 'Content created' })
}
)import { requireAuth } from '../middleware/auth'
import { requirePermission, requireAnyPermission } from '../middleware/permissions'
// Single permission required
app.post('/content/:id/publish',
requireAuth(),
requirePermission('content.publish'),
async (c) => {
const contentId = c.req.param('id')
// Publish content logic
return c.json({ message: 'Content published' })
}
)
// Any permission required
app.put('/content/:id',
requireAuth(),
requireAnyPermission(['content.update', 'content.publish']),
async (c) => {
const contentId = c.req.param('id')
const data = await c.req.json()
// Update content logic
return c.json({ message: 'Content updated' })
}
)
// Multiple permissions required
app.delete('/content/:id',
requireAuth(),
PermissionManager.requirePermissions(['content.delete', 'content.update']),
async (c) => {
const contentId = c.req.param('id')
// Delete content logic
return c.json({ message: 'Content deleted' })
}
)import { optionalAuth } from '../middleware/auth'
// Route accessible to both authenticated and anonymous users
app.get('/content/:id',
optionalAuth(),
async (c) => {
const user = c.get('user') // May be undefined
const contentId = c.req.param('id')
if (user) {
// Return full content for authenticated users
return c.json({ content: fullContent })
} else {
// Return limited content for anonymous users
return c.json({ content: publicContent })
}
}
)When you mount your own Hono routes next to a SonicJS app, you can authenticate requests with the same JWT that SonicJS issues. Three options, ordered by preference:
1. Use requireAuth() middleware (recommended — matches what SonicJS uses
internally, including the KV verification cache):
import { Hono } from 'hono'
import { requireAuth, createSonicJSApp } from '@sonicjs-cms/core'
const app = new Hono()
const adminRoutes = new Hono()
adminRoutes.use('*', requireAuth())
adminRoutes.get('/stats', (c) => {
const user = c.get('user') // { userId, email, role, ... }
return c.json({ user })
})
app.route('/api/admin', adminRoutes)
app.route('/', createSonicJSApp(config))2. Use AuthManager.verifyAuthRequest(c) when you need custom error
handling but still want the helper to extract the token + secret for you:
import { AuthManager } from '@sonicjs-cms/core'
adminRoutes.use('*', async (c, next) => {
const payload = await AuthManager.verifyAuthRequest(c)
if (!payload) return c.json({ error: 'Invalid token' }, 401)
if (payload.role !== 'admin') return c.json({ error: 'Forbidden' }, 403)
c.set('user', payload)
await next()
})3. Call AuthManager.verifyToken(token, secret) directly when you've
already extracted the token yourself. Always pass c.env.JWT_SECRET:
const token = c.req.header('Authorization')?.replace('Bearer ', '')
const payload = await AuthManager.verifyToken(token, c.env.JWT_SECRET)Don't call
AuthManager.verifyToken(token)without a secret. It falls back to a development-only placeholder, so any token signed with your realJWT_SECRETwill silently fail verification.
app.put('/content/:id',
requireAuth(),
async (c) => {
const user = c.get('user')
const contentId = c.req.param('id')
const db = c.env.DB
// Fetch content
const content = await db.prepare('SELECT * FROM content WHERE id = ?')
.bind(contentId)
.first()
// Custom authorization: user must be admin, editor, or content owner
const canEdit =
user.role === 'admin' ||
user.role === 'editor' ||
content.author_id === user.userId
if (!canEdit) {
return c.json({ error: 'You do not have permission to edit this content' }, 403)
}
// Update content logic
return c.json({ message: 'Content updated' })
}
)import { logActivity } from '../middleware/permissions'
app.delete('/content/:id',
requireAuth(),
requirePermission('content.delete'),
async (c) => {
const user = c.get('user')
const contentId = c.req.param('id')
const db = c.env.DB
// Delete content
await db.prepare('DELETE FROM content WHERE id = ?')
.bind(contentId)
.run()
// Log the deletion
await logActivity(
db,
user.userId,
'content.deleted',
'content',
contentId,
{ title: 'Sample Content' },
c.req.header('x-forwarded-for') || c.req.header('cf-connecting-ip'),
c.req.header('user-agent')
)
return c.json({ message: 'Content deleted' })
}
)import { Hono } from 'hono'
import { requireAuth, requireRole } from '../middleware/auth'
import { requirePermission } from '../middleware/permissions'
import { logActivity } from '../middleware/permissions'
const contentRoutes = new Hono<{ Bindings: Bindings; Variables: Variables }>()
// List content (public)
contentRoutes.get('/', async (c) => {
const db = c.env.DB
const { results } = await db.prepare('SELECT * FROM content WHERE status = ?')
.bind('published')
.all()
return c.json({ content: results })
})
// Get single content (public)
contentRoutes.get('/:id', async (c) => {
const db = c.env.DB
const content = await db.prepare('SELECT * FROM content WHERE id = ?')
.bind(c.req.param('id'))
.first()
if (!content) {
return c.json({ error: 'Content not found' }, 404)
}
return c.json({ content })
})
// Create content (requires content.create permission)
contentRoutes.post('/',
requireAuth(),
requirePermission('content.create'),
async (c) => {
const user = c.get('user')
const db = c.env.DB
const data = await c.req.json()
const contentId = crypto.randomUUID()
const now = Date.now()
await db.prepare(`
INSERT INTO content (id, title, body, author_id, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).bind(
contentId,
data.title,
data.body,
user.userId,
'draft',
now,
now
).run()
// Log activity
await logActivity(
db, user.userId, 'content.created', 'content', contentId,
{ title: data.title },
c.req.header('x-forwarded-for'),
c.req.header('user-agent')
)
return c.json({ id: contentId, message: 'Content created' }, 201)
}
)
// Update content (requires content.update permission OR ownership)
contentRoutes.put('/:id',
requireAuth(),
async (c) => {
const user = c.get('user')
const db = c.env.DB
const contentId = c.req.param('id')
const data = await c.req.json()
// Check ownership or permission
const content = await db.prepare('SELECT * FROM content WHERE id = ?')
.bind(contentId)
.first() as any
if (!content) {
return c.json({ error: 'Content not found' }, 404)
}
const canEdit =
user.role === 'admin' ||
user.role === 'editor' ||
content.author_id === user.userId
if (!canEdit) {
return c.json({ error: 'Permission denied' }, 403)
}
await db.prepare('UPDATE content SET title = ?, body = ?, updated_at = ? WHERE id = ?')
.bind(data.title, data.body, Date.now(), contentId)
.run()
await logActivity(
db, user.userId, 'content.updated', 'content', contentId,
{ title: data.title },
c.req.header('x-forwarded-for'),
c.req.header('user-agent')
)
return c.json({ message: 'Content updated' })
}
)
// Delete content (admin or editor only)
contentRoutes.delete('/:id',
requireAuth(),
requireRole(['admin', 'editor']),
requirePermission('content.delete'),
async (c) => {
const user = c.get('user')
const db = c.env.DB
const contentId = c.req.param('id')
await db.prepare('DELETE FROM content WHERE id = ?')
.bind(contentId)
.run()
await logActivity(
db, user.userId, 'content.deleted', 'content', contentId,
{},
c.req.header('x-forwarded-for'),
c.req.header('user-agent')
)
return c.json({ message: 'Content deleted' })
}
)
export { contentRoutes }Never use default JWT secret in production.
# Generate a secure random secret
openssl rand -base64 32
# Add to wrangler.toml
[vars]
JWT_SECRET = "your-secure-random-256-bit-secret"Update the salt in src/middleware/auth.ts:
// BEFORE (insecure)
const data = encoder.encode(password + 'salt-change-in-production')
// AFTER (secure)
const SALT = c.env.PASSWORD_SALT || 'your-unique-production-salt'
const data = encoder.encode(password + SALT)Better yet, use environment-specific salts:
# wrangler.toml
[vars]
PASSWORD_SALT = "your-unique-production-salt-value"Always use HTTPS in production:
setCookie(c, 'auth_token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production', // true in production
sameSite: 'Strict',
maxAge: 60 * 60 * 24
})Implement rate limiting for auth endpoints:
// Example with Cloudflare rate limiting
const RATE_LIMITS = {
login: 5, // 5 attempts
register: 3, // 3 attempts
resetPassword: 2 // 2 attempts
}Enforce strong passwords:
const strongPasswordSchema = z.string()
.min(12, 'Password must be at least 12 characters')
.regex(/[A-Z]/, 'Password must contain uppercase letter')
.regex(/[a-z]/, 'Password must contain lowercase letter')
.regex(/[0-9]/, 'Password must contain number')
.regex(/[^A-Za-z0-9]/, 'Password must contain special character')Implement email verification:
// On registration
const emailVerificationToken = crypto.randomUUID()
await db.prepare(`
UPDATE users SET
email_verified = 0,
email_verification_token = ?
WHERE id = ?
`).bind(emailVerificationToken, userId).run()
// Send verification email with tokenEnable 2FA for sensitive accounts:
ALTER TABLE users ADD COLUMN two_factor_enabled INTEGER DEFAULT 0;
ALTER TABLE users ADD COLUMN two_factor_secret TEXT;Always log security-sensitive actions:
await logActivity(
db,
user.userId,
'user.login',
'users',
user.userId,
{ ip: ipAddress, userAgent },
ipAddress,
userAgent
)Set security headers:
// In your main app
app.use('*', async (c, next) => {
await next()
c.header('X-Frame-Options', 'DENY')
c.header('X-Content-Type-Options', 'nosniff')
c.header('Referrer-Policy', 'strict-origin-when-cross-origin')
c.header('Permissions-Policy', 'camera=(), microphone=(), geolocation=()')
})Implement token rotation for long-lived sessions:
// Refresh token every 6 hours
const shouldRotate = (payload.iat + (6 * 60 * 60)) < Date.now() / 1000
if (shouldRotate) {
const newToken = await AuthManager.generateToken(
payload.userId,
payload.email,
payload.role
)
// Return new token in response header
c.header('X-New-Token', newToken)
}Configure CORS properly:
import { cors } from 'hono/cors'
app.use('*', cors({
origin: ['https://yourdomain.com'],
allowMethods: ['GET', 'POST', 'PUT', 'DELETE'],
allowHeaders: ['Content-Type', 'Authorization'],
credentials: true
}))Always validate and sanitize input:
import { z } from 'zod'
import { zValidator } from '@hono/zod-validator'
const updateUserSchema = z.object({
firstName: z.string().min(1).max(100),
lastName: z.string().min(1).max(100),
email: z.string().email()
})
app.put('/user/:id',
requireAuth(),
zValidator('json', updateUserSchema),
async (c) => {
const data = c.req.valid('json') // Validated data
// Update logic
}
)Problem: Invalid or expired token
Solutions:
- Check JWT_SECRET matches between token generation and verification
- Verify token hasn't expired (check
expclaim) - Ensure token is properly formatted (Bearer )
- Check KV cache for stale data
// Debug token
const parts = token.split('.')
const payload = JSON.parse(atob(parts[1]))
console.log('Token payload:', payload)
console.log('Expired?', payload.exp < Date.now() / 1000)Problem: Permission denied: content.update
Solutions:
- Check user role in database
- Verify role_permissions mapping
- Clear permission cache
- Check team membership (for team permissions)
-- Check user role
SELECT role FROM users WHERE id = 'user-id';
-- Check role permissions
SELECT p.name
FROM role_permissions rp
JOIN permissions p ON rp.permission_id = p.id
WHERE rp.role = 'editor';
-- Check user's team permissions
SELECT tm.role, tm.permissions
FROM team_memberships tm
WHERE tm.user_id = 'user-id';// Clear permission cache
PermissionManager.clearUserCache(userId)
PermissionManager.clearAllCache()Problem: Auth cookie not being sent/received
Solutions:
- Verify
secureflag matches protocol (HTTP vs HTTPS) - Check
sameSitesetting - Ensure domain matches
- Check browser console for cookie errors
// Development (HTTP)
setCookie(c, 'auth_token', token, {
httpOnly: true,
secure: false, // false for localhost HTTP
sameSite: 'Lax', // Lax for development
maxAge: 60 * 60 * 24
})
// Production (HTTPS)
setCookie(c, 'auth_token', token, {
httpOnly: true,
secure: true, // true for HTTPS
sameSite: 'Strict',
maxAge: 60 * 60 * 24
})Problem: Stale cached data
Solutions:
- Wait for cache expiration (5 minutes)
- Manually clear KV keys
- Check KV namespace binding
// Clear cached token verification
const cacheKey = `auth:${token.substring(0, 20)}`
await kv.delete(cacheKey)
// Clear user cache
await cache.delete(`user:${userId}`)
await cache.delete(`user:email:${email}`)Problem: Valid password rejected
Solutions:
- Check salt matches between hash and verify
- Verify password_hash in database
- Check for encoding issues
- Ensure consistent salt usage
// Test password hashing
const password = 'test123'
const hash1 = await AuthManager.hashPassword(password)
const hash2 = await AuthManager.hashPassword(password)
console.log('Hashes match?', hash1 === hash2) // Should be true
const valid = await AuthManager.verifyPassword(password, hash1)
console.log('Verification works?', valid) // Should be trueProblem: User not found or database errors
Solutions:
- Check D1 database binding in wrangler.toml
- Verify migrations have run
- Check table structure
# Check D1 binding
wrangler d1 execute DB --command "SELECT * FROM users LIMIT 1"
# Check table exists
wrangler d1 execute DB --command "SELECT name FROM sqlite_master WHERE type='table'"
# Run migrations
wrangler d1 execute DB --file=./migrations/001_initial_schema.sqlProblem: Token expired or invalid
Solutions:
- Check token expiration timestamps
- Verify token matches in database
- Ensure token hasn't been used
-- Check invitation token
SELECT id, email, invitation_token, invited_at, is_active
FROM users
WHERE invitation_token = 'token-value';
-- Check password reset token
SELECT id, email, password_reset_token, password_reset_expires
FROM users
WHERE password_reset_token = 'token-value';Problem: Activity logs not being created
Solutions:
- Check activity_logs table exists
- Verify logActivity is awaited
- Check for silent failures
// Add error handling
try {
await logActivity(db, userId, action, resourceType, resourceId, details, ip, ua)
} catch (error) {
console.error('Failed to log activity:', error)
// Continue - don't break main operation
}- User Management - Managing users and roles
- API Reference - Complete API documentation
- Deployment - Production deployment guide
- Permissions - Detailed permission system documentation