Layered admin authentication middleware for Hono on edge runtimes (Cloudflare Workers, Deno, Bun, Node.js).
One factory call gives you three middlewares:
adminAuth()— resolves "who is this request from?" via four methods, first match wins.requirePermission(perm)— RBAC gate with API-token scope clamping.csrfProtection()—X-Requested-Withsentinel for cookie-auth mutations.
Zero frontend lock-in. Works with any Kysely-compatible DB (D1, SQLite, Postgres, libSQL) — you supply the query closures, the library owns the auth logic.
Rolling your own admin auth is a minefield: replayable sessions from DB leaks, CSRF on cookie endpoints, dev bypass accidentally active in production, PATs granting more than the user's role allows. This package encodes the pattern that's been battle-tested across multiple @arraypress apps — hardened dev bypass, token hash-at-rest, CSRF sentinel, scope clamping — with a single factory and no hidden global state.
- Layered auth — session cookie → API token → Cloudflare Access → hardened dev bypass, in that priority.
- Hash-at-rest tokens — raw values go in the cookie /
Authorizationheader, SHA-256 hashes go in the DB. DB dumps never yield replayable credentials. - Three-gate dev bypass — environment flag AND loopback hostname AND explicit opt-in env var. All three must be true; any one false blocks the bypass. Cannot be accidentally activated in production even when a proxy forwards
Host: localhost. - CSRF protection — session-auth mutations require
X-Requested-With; bearer tokens and Cloudflare Access are exempt (no ambient-credential vector). - RBAC with scope clamping — tokens can never grant more than the user's role AND more than the token's scope set. A "Manager + read-only token" resolves to read-only.
- First-class disabled-user handling — treated as unauthenticated rather than 403, avoiding existence leaks.
- Zero ambient state — everything is injected via the factory. No module-level config, no globals.
npm install @arraypress/admin-auth honoPeer dependency: hono ^4.0.0.
Runtime dependencies (auto-installed): @arraypress/crypto, @arraypress/api-tokens, @arraypress/rbac.
import { createAdminAuth } from '@arraypress/admin-auth';
import type { AdminAuthVariables } from '@arraypress/admin-auth';
import { Hono } from 'hono';
// Your app's permission matrix — see @arraypress/rbac docs for shape.
const PERMISSIONS = {
'users:read': 10,
'users:write': 40,
'settings:write': 50,
};
// Your app's query closures — wire them to whatever DB you use.
const queries = {
async getAdminSession(db, hash) {
return db.selectFrom('admin_sessions').selectAll().where('id', '=', hash).executeTakeFirst();
},
async getAdminUserById(db, id) {
return db.selectFrom('admin_users').selectAll().where('id', '=', id).executeTakeFirst();
},
async getAdminUserByEmail(db, email) {
return db.selectFrom('admin_users').selectAll().where('email', '=', email).executeTakeFirst();
},
async getApiTokenByHash(db, hash) {
return db.selectFrom('api_tokens').selectAll().where('token_hash', '=', hash).executeTakeFirst();
},
async updateApiTokenLastUsed(db, id) {
return db.updateTable('api_tokens').set({ last_used_at: new Date().toISOString() }).where('id', '=', id).execute();
},
};
export const { adminAuth, requirePermission, csrfProtection } = createAdminAuth({
cookieName: 'sv_admin',
permissions: PERMISSIONS,
queries,
devBypass: {}, // enable with defaults — all three gates required
});
// Mount into your Hono app.
type Env = { Bindings: { DB: D1Database }; Variables: AdminAuthVariables };
const app = new Hono<Env>();
const admin = new Hono<Env>();
admin.use('*', adminAuth(), csrfProtection());
admin.get('/users', requirePermission('users:read'), (c) => {
const user = c.get('adminUser')!;
return c.json({ me: user.email });
});
app.route('/admin/api', admin);Browser admin UI flow. Reads cookieName, hashes the value with SHA-256, looks up the session via queries.getAdminSession, then resolves the user via queries.getAdminUserById.
Stored value in the DB is the hash, NOT the raw token — so a DB dump never yields replayable sessions. The cookie holds the raw token.
Disabled users fall through (treated as unauthenticated, not 403) to avoid leaking that a disabled account exists.
An invalid / expired / disabled session clears the cookie via deleteCookie before falling through, so the browser stops re-sending dead credentials.
Reads Authorization: Bearer <token>. If the value starts with apiTokenPrefix (default 'sc_pat_'), hashes it via @arraypress/api-tokens' hashToken and resolves via queries.getApiTokenByHash.
On success:
- Fires
queries.updateApiTokenLastUsed(db, id)non-blockingly (errors swallowed). - Sets
c.var.tokenScopesto the parsed scope list — consumed byrequirePermissionfor clamping.
On failure: returns 401 Invalid API token (instead of falling through) — bearers that start with the prefix are unambiguously PAT attempts.
Bearers that don't start with the prefix fall through, so you can layer other bearer-token schemes (e.g. third-party OAuth) before or after.
Reads cf-access-authenticated-user-email. Email must already exist in admin_users; the middleware never auto-provisions — that's a deliberate choice so CF Access can't bootstrap admin accounts without explicit consent from an Owner.
Unknown email → 403 Not authorized as admin.
Three compounding gates — all must be true:
c.env[environmentKey] === environmentValue(default:ENVIRONMENT === 'development')- Request hostname is in
loopbackHosts(default:localhost/127.0.0.1/0.0.0.0) c.env[enabledKey] === enabledValue(default:DEV_AUTH_BYPASS === '1')
If any gate fails, the bypass is skipped and the middleware returns 401 Unauthorized.
The three-gate design handles a real attack: a production Worker sitting behind a proxy that passes Host: localhost through would otherwise trigger a loopback-only bypass. The DEV_AUTH_BYPASS=1 opt-in and the ENVIRONMENT === 'development' check ensure production CAN'T accidentally allow this.
The synthetic user attached to the context defaults to an Owner-role (role: 50) user with id: 0. Override via devBypass.syntheticUser if you need different defaults for local testing.
Pass devBypass: undefined (or omit entirely) to disable the bypass feature completely.
Two-stage gate, chained after adminAuth():
- Scope clamp (API tokens only) — if
c.var.tokenScopesis set, the permission must either be in the scope list OR the token must hold the wildcardadminscope. Tokens can never grant more than their scope set, even if the user has higher role. - RBAC check — delegates to
@arraypress/rbac'shasPermission({ role }, permission, permissions).
Both must pass. Returns 401 Unauthorized if no user is set, 403 Insufficient scope if the scope clamp fails, 403 Forbidden if the role check fails.
Only applies to mutating methods (POST, PUT, DELETE, PATCH). Requires X-Requested-With header for session-authed requests.
Exempts API tokens and Cloudflare Access — these don't ride on cookies, so there's no ambient-credential CSRF vector.
The React admin UI should set X-Requested-With: XMLHttpRequest on every mutating fetch. An HTML <form> post can't set custom headers, so cross-origin CSRF via a hidden form is blocked.
| Field | Default | Description |
|---|---|---|
cookieName |
— (required) | Session cookie name. Each app should pick a distinct name to avoid cross-app collisions. |
apiTokenPrefix |
'sc_pat_' |
PAT bearer-prefix. Tokens not starting with this are treated as "not a PAT" and fall through to other auth. |
permissions |
— (required) | Permission matrix. Passed straight to @arraypress/rbac's hasPermission. |
queries |
— (required) | Query closures — see AdminAuthQueries. |
getDb |
(c) => c.get('db') |
Extract the DB handle from the context. Override if you attach it elsewhere. |
devBypass |
undefined |
Dev-bypass config. Omit to disable. |
loopbackHosts |
new Set(['localhost', '127.0.0.1', '0.0.0.0']) |
Hostnames considered "loopback" for the dev bypass. |
All queries receive db from config.getDb(c). Each should resolve to undefined (not throw) on miss — the middleware handles the miss as a fall-through.
| Query | Signature | Returns |
|---|---|---|
getAdminSession |
(db, hash: string) |
Session row where id === hash, or undefined. |
getAdminUserById |
(db, id: number) |
User row, or undefined. |
getAdminUserByEmail |
(db, email: string) |
User row (for CF Access flow), or undefined. |
getApiTokenByHash |
(db, hash: string) |
Token row where token_hash === hash, or undefined. |
updateApiTokenLastUsed |
(db, id: number) |
Ignored result — called fire-and-forget. |
| Field | Default | Description |
|---|---|---|
environmentKey |
'ENVIRONMENT' |
Env key checked for the environment gate. |
environmentValue |
'development' |
Env value required to match. |
enabledKey |
'DEV_AUTH_BYPASS' |
Env key checked for the explicit opt-in. |
enabledValue |
'1' |
Env value required to match. |
syntheticUser |
{ id: 0, email: 'dev', name: 'Developer', role: 50, disabled: 0 } |
User attached to context when bypass fires. |
Extend your Hono Env['Variables'] with AdminAuthVariables:
import type { AdminAuthVariables } from '@arraypress/admin-auth';
interface MyVariables extends AdminAuthVariables {
db: Kysely<Database>;
}
type Env = { Bindings: { DB: D1Database }; Variables: MyVariables };The middleware sets:
c.var.adminUser— always on success, shapeAdminUser.c.var.adminSession— only on cookie auth, shapeAdminSession.c.var.tokenScopes— only on API token auth, shapestring[].
Always set the CSRF header. Easiest via a client wrapper:
// Using @arraypress/api-client:
import { createClient } from '@arraypress/api-client';
const client = createClient('/admin/api', {
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
});Chain requirePermission after adminAuth per sub-router or per handler:
admin.route('/api/users', userRoutes);
userRoutes.get('/', requirePermission('users:read'), listHandler);
userRoutes.post('/', requirePermission('users:write'), createHandler);adminAuth() is one middleware — add your own before or after:
admin.use('*',
myRateLimit(), // before: rate-limit all admin routes
adminAuth(),
myAuditLog(), // after: log authed requests
csrfProtection(),
);- Never log raw session tokens or PATs — only their hashes.
console.log(rawToken)is a full-takeover bug waiting to happen viawrangler tail/ Logpush. - The dev bypass attaches
id: 0— make sure your write paths don't takeadminUser.idas a trusted FK without a role check too. In practice, audit log entries etc. will log the synthetic user, which is fine for local dev but a signal if it ever appears in production logs. - Disabled users fall through to other auth methods — they can't complete a session login, but a PAT they created before being disabled will also be rejected because the PAT → user lookup fails the
!user.disabledcheck. - API tokens bypass CSRF — intentionally, because they don't ride on cookies. Make sure your PAT-issuance flow treats token minting as a sensitive write (it is).
- CF Access emails are trusted verbatim — if your CF Access policy is misconfigured and allows arbitrary emails, this middleware will happily resolve them to admin users. Check your Access policy before enabling header-based auth.
The factory pattern makes the middleware easy to unit-test without a real DB — pass stub query closures:
import { createAdminAuth } from '@arraypress/admin-auth';
import { Hono } from 'hono';
const stubQueries = {
getAdminSession: async () => ({ id: 'stub-hash', user_id: 1 }),
getAdminUserById: async () => ({ id: 1, email: 'a@b', name: 'A', role: 50, disabled: 0 }),
getAdminUserByEmail: async () => undefined,
getApiTokenByHash: async () => undefined,
updateApiTokenLastUsed: async () => {},
};
const { adminAuth } = createAdminAuth({
cookieName: 'test_session',
permissions: {},
queries: stubQueries,
getDb: () => ({}), // no-op DB — queries ignore it
});
const app = new Hono();
app.use('*', adminAuth());
app.get('/', (c) => c.json({ user: c.get('adminUser') }));See tests/ for the full round-trip (session cookie → user resolution, PAT → scope attach, dev bypass gate combinations, CSRF enforcement).
MIT