Drop-in auth for Cloudflare Workers sites. Write the login plumbing once, reuse it across every site — each keeping its own database, fully isolated.
Shared, framework-agnostic auth primitives for Cloudflare Workers sites — signed sessions, HMAC tokens, PBKDF2 password hashing, owner allowlists, and OAuth (Google / GitHub / Microsoft) — plus an optional React login UI.
Each consuming site brings its own D1 database and its own secrets. Nothing in
this package links sites together. Security rests on your per-site
SESSION_SECRET, not on this code being private (it's public on purpose).
npm install @aswincloud/authThis package never shares a database between sites — every site you build gets its own isolated D1, so nothing is linked. Setting one up is one command:
# creates the D1, prints the binding to paste into wrangler.jsonc, applies the schema
npx aswincloud-auth-setup-db mysite-dbOr do it by hand:
npx wrangler d1 create mysite-db # → copy the database_id it prints# paste into wrangler.jsonc:# "d1_databases": [{ "binding": "DB", "database_name": "mysite-db",# "database_id": "<id>" }]
npx wrangler d1 execute mysite-db --remote \
--file=node_modules/@aswincloud/auth/schema.sqlschema.sql creates users, oauth_identities, and otp_codes — all
IF NOT EXISTS, so re-running is safe, and you add your own app tables
alongside them in your own migrations. Then set per-site secrets
(SESSION_SECRET, OAuth client ids).
// Core — pure Web Crypto, zero deps, runs on any Worker.import{hashPassword,verifyPassword,signToken,verifyToken,createSessionCookie,readSession,clearSessionCookie,isOwner,}from"@aswincloud/auth";// Optional React login UI — only multi-user sites need this.import{LoginPage}from"@aswincloud/auth/react";Owner-only Worker sites import only the core and never pull React in.
constsession={secret: env.SESSION_SECRET,cookieName: "sess"};// after verifying the user (e.g. Google OAuth) and checking the allowlist:if(!isOwner(env.OWNER_EMAILS,email))returnnewResponse("forbidden",{status: 403});constsetCookie=awaitcreateSessionCookie(session,email);// gate a request:constwho=awaitreadSession(session,req);if(!who)returnResponse.redirect(newURL("/api/auth/login",req.url),302);consthash=awaithashPassword(plainPassword);// on signup -> store in your D1constok=awaitverifyPassword(plainPassword,hash);// on loginconstsetCookie=awaitcreateSessionCookie(session,userId);One config drives all three providers. Configure only the ones you have
credentials for; configuredProviders() tells the UI which buttons to show.
import{startOAuth,handleOAuthCallback,configuredProviders,isOwner,createSessionCookie,}from"@aswincloud/auth";constorigin=newURL(req.url).origin;constoauth={clients: {google: {clientId: env.GOOGLE_CLIENT_ID,clientSecret: env.GOOGLE_CLIENT_SECRET},github: {clientId: env.GITHUB_CLIENT_ID,clientSecret: env.GITHUB_CLIENT_SECRET},microsoft: {clientId: env.MICROSOFT_CLIENT_ID,clientSecret: env.MICROSOFT_CLIENT_SECRET,tenantId: env.MICROSOFT_TENANT_ID},// optional, defaults to "common"},stateSecret: env.STATE_SECRET,redirectUri: (p)=>`${origin}/api/auth/oauth/${p}/callback`,};// GET /api/auth/oauth/:provider/startreturnstartOAuth(oauth,provider);// 302 to provider + signed CSRF state cookie// GET /api/auth/oauth/:provider/callbackconstr=awaithandleOAuthCallback(oauth,provider,req);if(!r.ok)returnResponse.redirect(`${origin}/login?oauth_error=${r.error}`,302);// r.user = { providerUserId, email, emailVerified } — verified by the provider.// Owner-only site: gate on the allowlist.if(!isOwner(env.OWNER_EMAILS,r.user.email))returnnewResponse("forbidden",{status: 403});constsetCookie=awaitcreateSessionCookie(session,r.user.email);// Multi-user site: look up / create the user in YOUR D1, then issue the session.// The package never touches a database — each site keeps its own, fully isolated.emailAllowed decides whether a provider-verified email may sign in, in one of
three modes a site picks (e.g. from an ACCESS_MODE env):
import{emailAllowed}from"@aswincloud/auth";// "public" → any authenticated email// "domain" → only emails in ACCESS_DOMAINS (owners, if any, also allowed)// "owners" → only emails in the OWNER_EMAILS allowlist (strict "only me")if(!emailAllowed({mode: env.ACCESS_MODE,// "public" | "domain" | "owners"email: r.user.email,owners: env.OWNER_EMAILS,// comma-separateddomains: env.ACCESS_DOMAINS,// comma-separated, e.g. "aswincloud.com"}))returnnewResponse("forbidden",{status: 403});parseAccessMode(raw) normalizes the env string (unknown ⇒ "owners", the safe
default). Pure and zero-dep.
For many sites that shouldn't each register their own OAuth client, a central
broker authenticates with the provider once and relays the verified email back
to each site, signed with a per-site shared secret (RELAY_SECRET):
import{signRelay,verifyRelay}from"@aswincloud/auth";// On the broker, after handleOAuthCallback succeeds:constrelay=awaitsignRelay(site.relaySecret,{ email, provider, nonce, providerUserId });// → 302 back to the site with ?relay=<relay>// providerUserId is optional: owner-only sites can omit it (token shape is then// unchanged); multi-user sites link accounts on (provider, providerUserId).// On the site's callback:constclaims=awaitverifyRelay(env.RELAY_SECRET,relayToken);// {email,provider,nonce,providerUserId?} | nullif(!claims||claims.nonce!==expectedNonce)returnforbidden();Short-lived (2 min) and purpose-bound; the nonce (echoed from the site's start
request) defends against replay. The broker Worker itself lives outside this
package; these are just the shared sign/verify helpers.
Provider notes: Google & Microsoft return verified emails; GitHub falls back to
/user/emails for the primary verified address. Microsoft uses your tenant in
the URL. CSRF is the HMAC-signed state cookie (checked against the returned
state param). Zero-dependency, pure fetch + Web Crypto.
import{SsoButtons}from"@aswincloud/auth/react";// providers usually comes from configuredProviders(oauth) on the server<SsoButtonsproviders={["google","github","microsoft"]}/>Renders <a href="/api/auth/oauth/{provider}/start"> buttons with real
provider logos. Drop it into <LoginPage ssoSlot={...} /> to get SSO + password
on one screen.
import{LoginPage}from"@aswincloud/auth/react";<LoginPageaction="/api/auth/login"onSuccess={()=>location.assign("/dashboard")}signupHref="/signup"forgotHref="/forgot"/>;Framework-agnostic — no next/* imports, so it runs on Next, Vite, or plain
React. Self-contained default styling; every element overridable via styles.
Navigation is yours via onSuccess. Drop SSO buttons in through ssoSlot.
A separate, opt-in entry point with DB-backed flows for multi-user sites:
signup + OTP verify, password reset, change password/username, self-service
email change, and account removal. The core stays zero-dep — only import /d1
where you need it.
Flows are functions, not HTTP handlers: each takes your D1 binding + plain
values and returns { ok: true, … } | { ok: false, error: "<code>" } — never
throws. You own the routing, the session cookie, and the email provider.
import{signup,verifyOtp,requestPasswordReset,resetPassword,changePassword,requestEmailChange,confirmEmailChange,removeUser,typeEmailSender,}from"@aswincloud/auth/d1";import{createSessionCookie}from"@aswincloud/auth";// You inject the email provider — the package never hardcodes one:constsendEmail: EmailSender=async({ to, subject, html, text })=>{awaitfetch("https://api.resend.com/emails",{method: "POST",headers: {Authorization: `Bearer ${env.RESEND_API_KEY}`,"Content-Type": "application/json"},body: JSON.stringify({from: env.MAIL_FROM, to, subject, html, text }),});};// POST /api/auth/forgotawaitrequestPasswordReset(env.DB,{
email,secret: env.TOKEN_SECRET, sendEmail,appUrl: origin,});// always { ok: true } — no account enumeration// POST /api/auth/resetconstr=awaitresetPassword(env.DB,{ token, newPassword,secret: env.TOKEN_SECRET});if(!r.ok)returnjson({error: r.error},400);// POST /api/auth/verify → issue the session yourself on successconstv=awaitverifyOtp(env.DB,{ email, code,secret: env.TOKEN_SECRET});if(v.ok)setCookie(awaitcreateSessionCookie(session,v.userId));Pure email templates ship too (passwordResetEmail, verifyEmail, otpEmail,
emailChangeEmail, accountDeletedEmail) returning { subject, html, text } —
use them or write your own. Matching React pages: ForgotPasswordPage,
ResetPasswordPage, VerifyEmailPage from @aswincloud/auth/react.
Branded emails — to keep your own look instead of the built-in template,
pass a render* override to the sending flows; it gets the dynamic bits and
returns { subject, html, text }:
awaitsignup(env.DB,{ email, password, secret, sendEmail,renderOtp: ({ code, ttlMinutes })=>myOtpEmail({ code, ttlMinutes })});// likewise: requestPasswordReset → renderReset({ resetUrl, ttlHours }),// requestEmailChange → renderEmailChange({ confirmUrl, newEmail, ttlHours }).When omitted, the built-in template is used (app name via appName).
Schema (0.2.0): the users table gained name + is_admin. New sites get
them from schema.sql. Existing DBs (created before 0.2.0) — run once:
ALTERTABLE users ADD COLUMN name TEXT;
ALTERTABLE users ADD COLUMN is_admin INTEGERNOT NULL DEFAULT 0;Sites using only the core primitives (no /d1) need no migration.
MIT