Getting Started
In these docs, @lumenize/mesh and @lumenize/routing are used to illustrate Lumenize Auth, but @lumenize/auth will work with any Cloudflare Workers/Durable Object system. The integration surface is the standard Authorization header: the auth hooks verify the JWT and forward it to your DO in the Authorization: Bearer <jwt> header — for both HTTP requests and WebSockets. See Integrating Alternative Auth for the exact header contract.
For a complete setup walkthrough including key generation, environment configuration, and Worker setup, see Lumenize Mesh: Getting Started.
Installation
npm install @lumenize/auth
Bootstrap: Your First Admin
Before anyone can log in with admin privileges, you need to designate a bootstrap admin. Set the LUMENIZE_AUTH_BOOTSTRAP_EMAIL environment variable:
# In .dev.vars or wrangler.jsonc (local development)
LUMENIZE_AUTH_BOOTSTRAP_EMAIL=you@example.com
# In production (no `wrangler variable` command exists, so use `wrangler secret` or the dashboard)
wrangler secret put LUMENIZE_AUTH_BOOTSTRAP_EMAIL
When this email address logs in, LumenizeAuth automatically grants isAdmin: true, emailVerified: true, and adminApproved: true. This check is idempotent — it runs on every login, so the bootstrap admin is restored even after a DO reset or if the environment variable is added after the subject already exists. No database seeding required. The bootstrap admin can then approve other subjects and promote them to admin via the subject management APIs.
The bootstrap subject has special protection: it cannot be demoted or deleted via API. To change the bootstrap admin, update the environment variable and deploy.
Worker Setup
Use createAuthRoutes to expose auth endpoints and createRouteDORequestAuthHooks to protect your DOs over HTTP (onBeforeRequest) or WebSockets (onBeforeConnect). Both integrate with routeDORequest. All auth configuration — including the URL prefix (default /auth), redirect URL, token TTLs, and issuer/audience — is read from environment variables:
import { LumenizeAuth, createAuthRoutes, createRouteDORequestAuthHooks } from '@lumenize/auth';
import { routeDORequest } from '@lumenize/routing';
export { LumenizeAuth };
export default {
async fetch(request: Request, env: Env): Promise<Response> {
// Auth routes - public endpoints
const authRoutes = createAuthRoutes(env);
const authResponse = await authRoutes(request);
if (authResponse) return authResponse;
// Protected routes - with auth hooks
const authHooks = await createRouteDORequestAuthHooks(env);
const response = await routeDORequest(request, env, {
...authHooks,
cors: true
});
return response ?? new Response('Not Found', { status: 404 });
}
};
createAuthRoutes
Creates a request handler for all auth endpoints. Call once at module level. All auth configuration (redirect, TTLs, issuer, audience, prefix) is read from environment variables — only Worker-level routing options are passed here.
const authRoutes = createAuthRoutes(env, { cors: true });
// Options object is optional — createAuthRoutes(env) uses all defaults
The only option is cors — see CORS Support for CorsOptions.
createRouteDORequestAuthHooks
Creates hooks for routeDORequest that verify JWTs and enforce two-phase access control. Reads issuer, audience, public keys, and rate limiter binding from environment variables.
const { onBeforeRequest, onBeforeConnect } = await createRouteDORequestAuthHooks(env);
Both hooks:
- Validate JWT signature against provided public keys (tries each until one succeeds)
- Verify
emailVerified && adminApproved(admins pass implicitly) - Return 401 for invalid/missing tokens
- Return 403 for valid tokens that fail the access check
- Return 429 when the rate limit is exceeded
- Forward the verified JWT to the DO in the
Authorization: Bearer <jwt>header — for both HTTP requests and WebSocket upgrades. Browsers don't allow custom headers on WebSocket connections, so the token arrives via the subprotocol list (see WebSocket token delivery). The hooks extract and verify it at the Worker level, then set theAuthorizationheader before forwarding. Your DO code sees a consistent header regardless of transport.
Your DO can then decode the JWT payload and authorize however you like — role checks, ownership guards, audit logging, etc. Ed25519 verification is a local crypto.subtle.verify() call (no network round trip, sub-millisecond), so DOs that want defense-in-depth can cheaply re-verify. DOs that trust the Worker hooks can simply base64url-decode the payload section. For a concrete example using @lumenize/mesh guards, see Security: Reusable Guards.
The example above uses vanilla Workers. If you're building on Hono, we also provide direct Hono support.
Email Provider
LumenizeAuth sends email for magic links, admin notifications, approval confirmations, and invites. Email delivery is delegated to a WorkerEntrypoint that you define and export from your Worker. The Auth DO calls it via the AUTH_EMAIL_SENDER service binding.
If AUTH_EMAIL_SENDER is not configured, emails are not delivered — the Auth DO debug-logs the email type and recipient instead. This lets you develop locally without an email provider, but you must configure one before deploying.
Without AUTH_EMAIL_SENDER, magic links and invites are silently dropped. Users will receive a "check your email" response but no email arrives. Always configure an email provider before deploying to production.
Cloudflare Email Sending is available on the Workers Paid plan (entry tier $5/month). If you'd rather stay on the free tier, see Using Resend instead — Resend has a 100-email-per-day free tier.
This package works with Cloudflare Email Sending (in open beta) using a binding rather than the REST API — no API key to manage. You add one entry to wrangler.jsonc, and CloudflareEmailSender handles the rest behind the same template/subject interface as every other sender.
1. Onboard your domain to Cloudflare Email Sending
In the Cloudflare dashboard, go to Email Services → Email Sending → Onboard Domain and pick a domain you already host on Cloudflare DNS. Cloudflare adds the required SPF, DKIM, DMARC, and bounce-handling records automatically. See the Cloudflare Email Sending docs for details.
2. Create your AuthEmailSender class
import { CloudflareEmailSender } from '@lumenize/auth';
export class AuthEmailSender extends CloudflareEmailSender {
from = 'auth@myapp.com'; // must be on a domain you've onboarded to Cloudflare Email Sending
// Override templates, subjects, replyTo, or appName here — see Customizing Email
}
That's it. CloudflareEmailSender handles delivery, default HTML templates, and default subject lines. Export this class from your Worker entry point alongside your other classes.
3. Add bindings to wrangler.jsonc
Two bindings: the self-referencing service binding that lets the Auth DO call your AuthEmailSender, and the send_email binding that CloudflareEmailSender uses to send.
{
"services": [
{
"binding": "AUTH_EMAIL_SENDER",
"service": "your-worker-name", // must match your wrangler.jsonc "name"
"entrypoint": "AuthEmailSender"
}
],
"send_email": [
{
"name": "EMAIL"
}
]
}
No secrets to manage — the binding authenticates automatically.
Going further
- Override templates, subjects,
replyTo, orappName, or plug in a different provider (Postmark, SES, SendGrid, etc.) — see Customizing Email. - Prefer Resend over Cloudflare — see Using Resend instead.
Key Rotation
The BLUE/GREEN pattern enables zero-downtime key rotation. Tokens are verified against each public key until one succeeds.
Secrets and Variables
Secrets - Generate two key pairs with:
# Generate and display private key (copy for next step)
openssl genpkey -algorithm ed25519 | tee /dev/stderr | openssl pkey -pubout
Set in the dashboard or via command line:
# Primary key pair (signs new tokens)
wrangler secret put JWT_PRIVATE_KEY_BLUE
wrangler secret put JWT_PUBLIC_KEY_BLUE
# Secondary key pair (verifies old tokens during rotation)
wrangler secret put JWT_PRIVATE_KEY_GREEN
wrangler secret put JWT_PUBLIC_KEY_GREEN
Paste each key (including -----BEGIN/END----- lines) when prompted.
Variable - Set PRIMARY_JWT_KEY to BLUE in the dashboard or wrangler.jsonc/wrangler.toml
Key Rotation Procedure
Every 3 months for 6 month lifetime:
- Generate new key pair for the secondary slot (GREEN if BLUE is primary)
- Switch primary - change
PRIMARY_JWT_KEY(to GREEN if BLUE is primary) - Deploy - environment variables/secrets only become active on deploy
Rate Limiting
Rate limiting runs at the Worker level, not in the LumenizeAuth DO. This keeps the singleton DO focused on business logic and leverages Worker horizontal scaling.
Cloudflare DDoS/Bot Protection
Fingerprint-reputation filtering handles volumetric attacks automatically. No configuration needed.
Magic-Link Endpoint: Turnstile
The unauthenticated POST {prefix}/email-magic-link endpoint needs abuse protection. Cloudflare Turnstile proves the requester is human before the request reaches your Worker — it's free, GDPR-compliant, and requires no CAPTCHA interaction.
If TURNSTILE_SECRET_KEY is set, createAuthRoutes verifies every magic-link request against Cloudflare and returns 403 on failure. If the key is not set, Turnstile verification is skipped and a console warning is emitted. See Get Started with Turnstile to obtain your secret key.
Without Turnstile, anyone can request magic links for arbitrary email addresses, generating email traffic and potentially exhausting your email sending quota. Always configure TURNSTILE_SECRET_KEY before deploying to production.
Your frontend embeds the Turnstile widget and includes the resulting token as cf-turnstile-response in the request body alongside email (see Request Magic Link for the full request example). createAuthRoutes extracts it and verifies with Cloudflare before forwarding to the DO. Returns 403 if verification fails.
Authenticated Routes: Rate Limiter
LUMENIZE_AUTH_RATE_LIMITER protects all authenticated routes handled by createRouteDORequestAuthHooks, keyed on sub from the decoded JWT. Uses Cloudflare's Rate Limiting binding.
If the binding is not configured, rate limiting is skipped and a console warning is emitted.
Without per-subject rate limiting, a compromised or malicious JWT can flood your Durable Objects with requests. Always add the LUMENIZE_AUTH_RATE_LIMITER binding before deploying to production.
Setup
Add the rate limiting binding to your wrangler.jsonc:
{
"rate_limits": [
{ "binding": "LUMENIZE_AUTH_RATE_LIMITER", "namespace_id": "1001", "simple": { "limit": 100, "period": 60 } }
]
}
Add the Turnstile secret key:
# Local development (.dev.vars)
TURNSTILE_SECRET_KEY=0x4AAAAAAA...
# Use wrangler or dashboard for production
wrangler secret put TURNSTILE_SECRET_KEY
Both functions read all configuration from env automatically — TURNSTILE_SECRET_KEY, LUMENIZE_AUTH_RATE_LIMITER, and all auth config variables (see Worker Setup).
Rate Limiting Caveats
The rate limiting binding is per Cloudflare location (not globally consistent) and eventually consistent. It is designed for abuse prevention, not precise accounting. This is fine for auth rate limiting — the goal is to stop floods, not enforce exact quotas.
Audit Logging
All authentication and subject management operations produce structured JSON log entries via @lumenize/debug. These are emitted to console.debug and captured by Cloudflare's observability pipeline — no additional tables, endpoints, or retention logic needed.
Every audit entry includes a hierarchical namespace (auth.LumenizeAuth.{category}.{action}), a level (info or warn), a message, and a data object with fields like targetSub, actorSub, and action-specific details. Security-relevant events (failed logins, access denials, token revocations, subject deletions) use warn level; normal operational events (subject creation, logins, invites) use info.
Filter in the Cloudflare dashboard by namespace and level:
auth.LumenizeAuth— all auth audit eventsauth.LumenizeAuth.login— login successes and failuresauth.LumenizeAuth:warn— security-relevant events only
Audit logging is always active when DEBUG includes auth (or *). In production, set DEBUG=auth as an environment variable. In tests, inject it via miniflare.bindings in your vitest.config.js.