emdash-better-auth
v0.4.0
Published
Email/password + social (Google, GitHub) authentication for EmDash CMS, powered by Better Auth with prebuilt Better Auth UI sign-in/sign-up pages, mandatory email verification, and an admin settings page.
Maintainers
Readme
🔐 emdash-better-auth
Drop-in email/password + social login for EmDash, powered by Better Auth
Turn EmDash from a passkey-first CMS admin into a full auth backend for your public-facing app or membership site — signup, email verification, password reset, and Google/GitHub login — in one line of config, with zero database migrations.
Live demo · npm · Quickstart
Email/password (and optional social — Google, GitHub) authentication for EmDash sites, powered by Better Auth with prebuilt Better Auth UI (HeroUI) sign-in / sign-up pages.
It registers as an EmDash AuthProviderDescriptor, so it plugs in with a single line and ships everything it needs — the API handler, the styled auth pages, and a light/dark/system theme toggle — with no per-site database migrations.
[!NOTE] Built on Better Auth, so it's a foundation, not a one-off: sign-ups flow onto EmDash's own
userstable through a Better Auth adapter, which opens the door to the wider Better Auth ecosystem (more social providers, 2FA, username login, organizations, Stripe billing) as future additions on the same bridge.
✨ Highlights
| | |
| --- | --- |
| 🧩 One-line install | Register betterAuthProvider() and you get sign-in, sign-up, forgot/reset password, and sign-out routes. |
| 👤 Real EmDash users | Sign-ups land in EmDash's own users table (RBAC-governed) — no second user store, no migrations. |
| 🎨 Prebuilt, themed UI | HeroUI sign-in/sign-up pages with a dark / light / system toggle, self-contained (won't touch your site's styles). |
| ✉️ Mandatory email verification | Blocks bot signups; re-sends the link on blocked login and auto-signs-in on click. |
| 🔑 Social login | Google & GitHub out of the box, per-provider, data-driven and easy to extend. |
| ⚙️ Admin settings page | Configure verification, canonical URL, and OAuth credentials from the admin UI — no redeploy. |
| 🔔 Username support | Sign in with username, public identity without exposing email. Always required. |
| 📝 Subscriber profile editing | Public, login-gated account settings at /account — edit name, avatar, username, and bio (stored in users.data), plus password/linked-accounts security tab. |
📸 Screenshots
The hero above shows the sign-up page (light). Here's dark mode and the admin config surface:
Quickstart
pnpm add emdash-better-auth
pnpm add -D @tailwindcss/vite # required — compiles the auth-page styles
wrangler secret put BETTER_AUTH_SECRET # required — `openssl rand -base64 32`// astro.config.mjs
import react from "@astrojs/react";
import tailwindcss from "@tailwindcss/vite";
import { betterAuthProvider, betterAuthSettingsPlugin } from "emdash-better-auth";
export default defineConfig({
output: "server",
vite: { plugins: [tailwindcss()] }, // required for the HeroUI auth styles
integrations: [
react(), // auth pages are React islands
emdash({
/* ...your database/storage... */
authProviders: [betterAuthProvider()], // sign-in/up, /auth/*, /login, /signup
plugins: [betterAuthSettingsPlugin()], // optional: admin settings page
}),
],
});Deploy, then visit /login or /signup. Email verification is on by default — see
Email (a working email provider is required).
Social logins (Google, GitHub) and everything else are optional; details below.
What you get
- Real EmDash users. A Better Auth sign-up creates a row in EmDash's own
userstable, so the account is a first-class EmDash user: visible in the admin and governed by EmDash RBAC (therolecolumn). New sign-ups default to the lowest role (subscriber,10) so hitting/signupnever grants admin access. - Prebuilt, styled auth pages at
/auth/*(plus/loginand/signupaliases) — sign-in, sign-up, forgot-password, reset-password, sign-out. Self-styled with HeroUI; they don't touch your site's theme. - Light / Dark / System theme toggle on the auth pages, persisted per visitor.
- Session bridging. After sign-in/up the plugin writes EmDash's own session cookie, so the same account works for both the public site and
/_emdash/admin(subject to role). - Portable storage. Better Auth's session/account/verification state lives in EmDash's shared plugin storage (
getAuthProviderStorage), namespacedauth:better-auth— no custom tables, no migration files. - Social sign-in (Google, GitHub). Enabled per-provider when credentials are configured (env vars or the admin UI). Data-driven and easy to extend to more providers — see Enabling social sign-in.
- Optional admin settings UI. An opt-in companion plugin (
betterAuthSettingsPlugin()) adds a Better Auth settings page to the EmDash admin sidebar — verification toggles, canonical URL, the Better Auth secret, and per-provider social credentials — read at request time with env-var fallback. See Admin settings. - Username sign-in. Users must register with a username (unique handle) and can sign in with it instead of email. The username is public; the email is kept private. See Username support.
Requirements
- EmDash
^0.30.0 - React
>=19.2.6and react-dom>=19.2.6(HeroUI peer requirement; bump your site if it pins an older patch) @astrojs/reactintegration in your Astro config (the auth pages are React islands)@tailwindcss/vitewired into your Astro config — required. Better Auth UI (HeroUI) ships its styles as Tailwind v4 source, which must be compiled. Without this the auth pages render unstyled.- A Cloudflare Workers deployment (this plugin reads secrets from the Worker
envand uses EmDash's D1-backed storage). Other adapters may work but are untested.
Install
pnpm add emdash-better-auth
# peer/build deps if your project doesn't already have them:
pnpm add -D @tailwindcss/viteSupply-chain note: the
@better-auth-ui/*packages are released frequently. If your project enforces a pnpmminimumReleaseAgecooldown, add"@better-auth-ui/*"tominimumReleaseAgeExcludeinpnpm-workspace.yaml.
Setup
1. Register the provider
// astro.config.mjs
import cloudflare from "@astrojs/cloudflare";
import react from "@astrojs/react";
import tailwindcss from "@tailwindcss/vite";
import { betterAuthProvider } from "emdash-better-auth";
import { defineConfig } from "astro/config";
import emdash from "emdash/astro";
export default defineConfig({
output: "server",
adapter: cloudflare(),
// Required: compiles the HeroUI auth styles. Output is scoped to the auth
// island, so it does not affect your site's own styling.
vite: { plugins: [tailwindcss()] },
integrations: [
react(),
emdash({
// ...your database/storage config...
authProviders: [betterAuthProvider()],
}),
],
});That's it — the plugin injects all of these routes for you:
| Route | Purpose |
| --- | --- |
| /api/auth/[...all] | Better Auth API handler (sign-in, sign-up, callbacks, get-session, sign-out) |
| /auth/[...path] | Prebuilt auth UI: /auth/sign-in, /auth/sign-up, /auth/forgot-password, /auth/reset-password, /auth/sign-out |
| /login | Friendly alias → /auth/sign-in |
| /signup | Friendly alias → /auth/sign-up |
| /account/[...path] | Subscriber profile settings: /account/account, /account/security |
| /account | Friendly alias → /account/account (login-gated) |
Do not create your own src/pages/login.astro / signup.astro / api/auth/* — they would conflict with the injected routes.
Do not create your own src/pages/login.astro / signup.astro / api/auth/* — they would conflict with the injected routes.
Two login doors, on purpose. EmDash's native passkey login at
/_emdash/admin/login is left untouched, so existing passkey admins keep
working. The Better Auth pages (/auth/sign-in, /login) are an additional
email/password door. Because a Better Auth account is an EmDash user (same
users table), an admin (role ≥ 50) can sign in through either door and
get full CMS access. Don't redirect /_emdash/admin/login to Better Auth
unless every admin has an email/password credential — passkey-only admins would
be locked out.
2. Set the runtime secrets
The plugin reads these from the Worker environment at request time. Set them as Cloudflare secrets (not just .env — .env is build-time only and is not available to the deployed Worker):
# Required. Generate with: openssl rand -base64 32
wrangler secret put BETTER_AUTH_SECRET
# Optional — enables social sign-in (per provider; omit for email/password only)
wrangler secret put GOOGLE_CLIENT_ID
wrangler secret put GOOGLE_CLIENT_SECRET
wrangler secret put GITHUB_CLIENT_ID
wrangler secret put GITHUB_CLIENT_SECRET| Variable | Required | Notes |
| --- | --- | --- |
| BETTER_AUTH_SECRET | Yes | Signing/encryption secret. At least 32 chars, high entropy. |
| GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET | No | Enables Google sign-in when both are set (or set them in the admin UI). Disabled if either is missing or left as a placeholder. |
| GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET | No | Enables GitHub sign-in when both are set (or via the admin UI). Same rules as Google. |
| BETTER_AUTH_URL | No | Canonical public origin (e.g. https://blog.example.com). See below. |
Base URL / canonical origin
Verification and password-reset emails contain absolute links (a link opened later from a mail client has no "current origin", so relative URLs are impossible). The plugin resolves that canonical origin at request time, in priority order:
BETTER_AUTH_URL(orBETTER_AUTH_BASE_URL) env var — an explicit override. Highest priority.- Astro's
siteconfig — if you setsite:inastro.config.mjs(common for canonical URLs / sitemaps), the plugin reuses it automatically. No extra config. - The request origin — zero-config fallback for a single-domain site.
So adoption is friction-tiered: a single-domain blog needs nothing; a blog
that already sets site: gets it for free; only multi-domain setups need
the env var.
Multi-domain / proxied deployments must set tier 1 or 2. If your app is reachable on more than one hostname (e.g. a custom domain and the raw
*.workers.devURL, or behind a proxy), the request origin (tier 3) is ambiguous — a request that happens to arrive on theworkers.devhost would otherwise bake that host into email links. Setsite:inastro.config.mjs(recommended — also fixes canonical URLs/RSS) or theBETTER_AUTH_URLsecret. The origin the request came in on is always added totrustedOrigins, so logging in via the non-canonical host still works; only the generated links are forced to the canonical origin.
Client-side calls are already origin-relative (resolved from window.location),
so they need none of this.
Enabling social sign-in
Ships with Google and GitHub. Each provider turns on only when BOTH its client ID and secret are configured — via env vars (above) or the admin UI (see Admin settings). A half-configured provider (id but no secret, or vice versa) stays off. The auth pages show a provider's button automatically once it's enabled; no code change needed.
For each provider you want:
- Create an OAuth app in the provider's developer console:
- Google — Google Cloud Console, an OAuth Web application client.
- GitHub — Settings → Developer settings → OAuth Apps → New OAuth App.
- Register the authorized redirect / callback URL (Better Auth's default
callback path — the admin settings page also shows this per provider, ready
to copy):
- Google:
https://<your-domain>/api/auth/callback/google - GitHub:
https://<your-domain>/api/auth/callback/github
- Google:
- Provide the credentials, either as Worker secrets:
…or by pasting them into the Better Auth admin settings page (per-provider section, with its own Save button). Saved settings win over env vars per field.wrangler secret put GOOGLE_CLIENT_ID wrangler secret put GOOGLE_CLIENT_SECRET # and/or wrangler secret put GITHUB_CLIENT_ID wrangler secret put GITHUB_CLIENT_SECRET
For local development, put the same values in .dev.vars so wrangler dev
picks them up.
Adding another provider (Better Auth supports Apple, Discord, Microsoft,
GitLab, Facebook, X, …): extend the SocialProviderId union in auth.ts and add
an entry to SOCIAL_PROVIDERS in settings.ts ({ id, label, envPrefix },
where id is Better Auth's provider id). The admin section, kv keys, env
fallback (<PREFIX>_CLIENT_ID/SECRET), and callback URL are all derived from
that list — no other wiring needed. Facebook was intentionally left out of the
initial set to keep it simple.
3. Deploy
pnpm build && wrangler deployThen visit /login or /signup.
Admin settings (optional)
By default the plugin is configured entirely through Worker env vars (above). If you also want to tune it from the EmDash admin UI — without redeploying — register the companion settings plugin alongside the auth provider:
// astro.config.mjs
import { betterAuthProvider, betterAuthSettingsPlugin } from "emdash-better-auth";
emdash({
authProviders: [betterAuthProvider()],
plugins: [betterAuthSettingsPlugin()], // adds a "Better Auth" admin sidebar page
});It adds a Better Auth entry to the admin sidebar (under Plugins) at
/_emdash/admin/plugins/better-auth-settings/settings, rendering a settings
form. Saved values persist to the plugin's key-value store (the options
table, keys plugin:better-auth-settings:settings:<field>); the auth provider
reads them back at request time via getPluginSettings(...).
Why a separate plugin? Better Auth registers as an EmDash auth provider, and
that registration path has no admin-settings surface — only the full plugin
system (plugins: [...]) can contribute admin UI. betterAuthSettingsPlugin()
is a tiny native plugin whose only job is to own that settings page + storage.
Why a custom page rather than EmDash's declarative settingsSchema
auto-render? The auto-rendered form only surfaces inside the admin Plugins
manager page, which loads the plugin-list endpoint first. On the EmDash
version this was built against, that endpoint returns a 500 (a pre-existing
core bug, unrelated to this plugin — it throws before this plugin is reached),
so a settingsSchema form never displays. So the plugin renders its own page
via adminPages + a routes.admin handler (the same mechanism emdash-smtp
uses) — it gets its own sidebar link and route and doesn't touch the broken
list endpoint. If/when that core bug is fixed, this could be simplified back to
settingsSchema.
The form exposes:
| Setting | Type | Notes |
| --- | --- | --- |
| Require email verification | toggle | Block unverified sign-in (default on). |
| Re-send verification on sign-in | toggle | Re-send the link on a blocked login (default on). |
| Auto sign-in after verification | toggle | Log in on link click (default on). |
| Canonical site URL | url | Overrides BETTER_AUTH_URL / site: for email links. |
| Better Auth secret | secret | Session signing key. See the security note below. |
| Social sign-in (Google, GitHub) | string / secret per provider | One stacked section per provider — client ID + secret, plus the read-only callback URL to register. Its own Save button. |
Precedence: a saved admin setting wins over the matching env var, which wins over the built-in default. Every field is optional — leave one blank and the env var / default is used. So the settings plugin is purely additive: with all fields blank it changes nothing, and the site keeps working via env vars if the settings can't be read.
Security — secrets are stored in the database, not encrypted. The masked secret field only hides the input in the UI; the value is persisted in the
optionstable (D1) as plaintext, the same wayemdash-smtpstores its API key. For the Better Auth secret specifically — the session signing key — DB storage is meaningfully weaker than a Worker secret: if the database leaks, sessions can be forged. Recommended: leave the "Better Auth secret" field blank and keepBETTER_AUTH_SECRETas a Worker secret. Use the admin field for it only if you accept that tradeoff (e.g. pending an EmDash feature for encrypted-at-rest secret storage).
Watch out for browser autofill on the credential fields. A password manager may silently populate the "Better Auth secret" and the per-provider client ID / secret fields with saved credentials for the site; clicking Save would then persist those junk values (and, for the signing key, rotate every session; for a provider, enable it with bad credentials). Blank secret fields are left untouched on save — but autofill makes them non-blank. Before saving any section, confirm its fields are actually empty (or hold the value you intend). If a wrong value gets saved, clearing it in the form won't remove it (blank = "keep existing"); delete the row directly, e.g.
DELETE FROM options WHERE name = 'plugin:better-auth-settings:settings:betterAuthSecret';
Note on the post-sign-up redirect. The auth UI decides whether to route to the "verify your email" page after sign-up based on a client-side flag that defaults to
true(matching the default server config). If you turn Require email verification off in the admin UI, also passrequireEmailVerification={false}toAuthView(or the auth page) so the UI stops sending new users to the verify page. The server remains authoritative either way; this only affects the redirect.
Username support
Better Auth's username plugin adds a unique handle for public identity without exposing the email address. The plugin implements this via plugin storage only — no EmDash core changes or migrations needed.
| Feature | Details |
| --- | --- |
| Username sign-in | Users sign in with username instead of email (authClient.signIn.username({ username, password })). |
| Unique index | Username uniqueness is enforced atomically by the storage layer (uniqueIndexes: ["username"]), no check-then-write race. |
| Email private | A verified email is still required for account recovery, but never shown publicly. |
| Always required | Username is mandatory for all new sign-ups; no admin toggle needed. |
| Display name | displayUsername is stored alongside (the user-typed form, normalized for storage). |
How it works:
- Username records are stored in EmDash's
_plugin_storagetable underauth:better-auth:usernames. - The EmDash adapter automatically creates/syncs/cleans up username records on user create/update/delete.
- Better Auth UI renders the username field automatically when the username plugin is enabled.
No core migration needed. EmDash's users table has no username column — the plugin stores usernames in its own storage collection with a unique index. This matches Better Auth's recommendation for databases that don't support native unique constraints on user columns.
Why username is always required: A mix of users with and without usernames would create a messy, inconsistent experience for bylines, comments, and public identity features. Email remains required for account recovery (password reset, etc.), but the username is the public-facing identity.
Subscriber profile editing
Better Auth UI's Settings component provides a login-gated account settings page where users can manage their profile and security.
Public routes
| Route | Purpose |
| --- | --- |
| /account | Friendly alias → /account/account (login-gated) |
| /account/account | Main settings: name, avatar, username, bio |
| /account/security | Password management and linked accounts |
Users must be authenticated to access /account; unauthenticated requests are redirected to /auth/sign-in. (The view segments — account, security — are Better Auth UI's own settings view paths.)
Bio field
The bio field is stored in EmDash's users.data JSON column — no new database columns or migrations required. The EmDash adapter merges bio into the data object on read/update, preserving other keys already stored in data.
Avatar
Avatars are editable via Better Auth UI's built-in avatar control. No custom upload handler is configured, so Better Auth UI resizes the selected image and stores a compact data URL directly in user.image (→ users.avatar_url). This works with no backend wiring.
R2-backed avatar upload (storing the file via EmDash's media pipeline and saving a servable media URL) is a planned follow-up; until then, avatars are inline data URLs.
Usage in your site
No extra setup is required beyond registering betterAuthProvider(). The routes are injected automatically, and the AccountView island renders Better Auth UI's Settings component with the same authClient, HeroUI styling, theme plugin, and username plugin used for sign-in/sign-up. The bio field is contributed via the provider's additionalFields.
Email: password reset & verification
Better Auth doesn't send email itself — it hands the plugin a message + link,
and the plugin delivers it through EmDash's email pipeline (runtime.email).
So these features work only if the site has an email provider configured:
- Password reset (
/auth/forgot-password) — sends a reset link. - Email verification — a verification email is sent on sign-up.
The plugin is provider-agnostic: it never depends on a specific email
service. It calls runtime.email.send(..., "system"), and whatever
email:deliver provider the site registers actually sends it. To enable email
on a site, install an EmDash email provider — e.g.
emdash-smtp (supports Resend,
SES, SMTP, and more) — add it to emdash({ plugins: [...] }), pick a provider
in Admin → Settings → Email, and set that provider's key (e.g.
RESEND_API_KEY) as a Worker secret.
Email verification is mandatory. emailAndPassword.requireEmailVerification
is true, so a new account cannot sign in until its address is confirmed. On
sign-up a verification link is emailed; on any sign-in attempt by an unverified
user Better Auth rejects the login (EMAIL_NOT_VERIFIED), the UI routes to
/auth/verify-email, and — because emailVerification.sendOnSignIn is true —
the link is re-sent so the user doesn't have to find the original email.
Clicking the link verifies the address and (via autoSignInAfterVerification)
logs them straight in. This is deliberate: it stops bot-created accounts from
ever becoming usable. email_verified flips true when the link is clicked.
Because verification is mandatory, a working email provider is required. Unlike password reset (which just fails quietly without a provider), signup is a dead end if verification emails can't be delivered — users create an account they can never log into. Configure an email provider before going live. In dev, EmDash's built-in console provider logs the email so you can copy the link from the terminal. To relax this to "soft" verification (email sent, but login not blocked), turn off Require email verification in the admin settings (see Admin settings) — or, if you don't use the settings plugin, set
emailAndPassword.requireEmailVerification: falseinauth.ts.trueis the default in both cases.
Graceful for password reset. If no provider is configured, password-reset emails simply aren't sent (logged, never thrown) rather than crashing auth.
No database migration required
This plugin ships no migration files and you don't need to write any. It reuses tables EmDash already manages:
users— EmDash's core users table (created by EmDash's own migrations, which run automatically on first request). Sign-ups insert rows here._plugin_storage— EmDash's shared plugin-storage table (also core). Better Auth'saccounts/sessions/verificationsrecords are stored here under theauth:better-authnamespace. The indexes for those collections are created automatically at runtime from thestorageconfig inbetterAuthProvider()— no SQL, no migration step.
So on a fresh site the correct sequence is just: register the provider, set BETTER_AUTH_SECRET, deploy, and load a page once so EmDash applies its core migrations. If you inspect the D1 database, do not add or expect auth_* tables — this plugin does not use any bespoke tables.
How it works
- User model → EmDash
userstable via a custom Better Auth adapter (emdashAdapter), with field mapping (emailVerified→email_verified,image→avatar_url, etc.). - account / session / verification → EmDash plugin storage (
getAuthProviderStorage(db, "better-auth", ...)), stored in the shared_plugin_storagetable under theauth:better-authnamespace. No custom tables. - The auth pages are a React island (
AuthView) rendering Better Auth UI's<Auth path=... />insideQueryClientProvider+AuthProvider, pointed at the/api/authhandler. HeroUI styles are compiled by Tailwind and loaded only on the auth routes. - Session bridge: on a successful sign-in/up the injected route calls
context.session.set("user", { id }); on sign-out it destroys that session. EmDash's own middleware reads that key, so the login is recognized site-wide.
Customization
Default role for new sign-ups is subscriber (
ROLE_SUBSCRIBER = 10). Promote users to higher roles from the EmDash admin UI; once promoted they can also access/_emdash/admin.Post-auth redirect defaults to
/. The pages honor a?redirect=<path>query param.Theme defaults to system; visitors can switch Light/Dark/System and the choice persists in
localStorage.Use the island directly if you want auth UI on your own page instead of the injected routes:
--- import AuthView from "emdash-better-auth/ui"; --- <AuthView path="sign-in" redirectTo="/" client:load />Use the client in your own components:
import { authClient } from "emdash-better-auth/client"; await authClient.signIn.email({ email, password });
Package exports
| Export | What |
| --- | --- |
| . | betterAuthProvider(), createBetterAuth, emdashAdapter, PROVIDER_ID, BETTER_AUTH_STORAGE_CONFIG, ROLE_SUBSCRIBER |
| ./client | Better Auth browser client (authClient) |
| ./ui | AuthView React island |
| ./route | The /api/auth/[...all] handler (injected automatically) |
| ./pages/auth, ./pages/login, ./pages/signup | The injected Astro pages |
| ./admin | Login button shown on EmDash's admin login page |
| ./auth, ./adapter | Lower-level createBetterAuth / emdashAdapter |
Notes & limitations
- Cloudflare Workers–oriented. Secrets come from the Worker
env; storage uses EmDash's D1-backed plugin storage. - Social sign-in (Google, GitHub) requires real credentials (Worker secrets or the admin UI); each provider is hidden until both its ID and secret are set.
- Dependency footprint. The prebuilt UI pulls in HeroUI v3 + several TanStack packages. The client bundle loads only on the
/authroutes, not your content pages. - Email verification is mandatory and requires a working email provider. Sign-up creates the account but blocks login until the emailed link is clicked, so a site with no
email:deliverprovider configured leaves new users unable to log in. Configure a provider (see Email above) before enabling public sign-up, or setrequireEmailVerification: falseinauth.tsto soften it.
Reusing on another EmDash site
The plugin is designed to be site-agnostic — nothing is hardcoded to a particular site:
- No hardcoded URLs or brand. The public origin is derived from the
request; the auth header's brand name is read from EmDash site settings
(
getSiteSettings().title), falling back to "Account". - No database migrations. It reuses EmDash's
usersand_plugin_storagetables (auto-created by EmDash core); storage indexes are created at runtime. - Secrets come from the Worker
env, not committed code.
To use it on another site: install the package, add the setup from
Setup (register betterAuthProvider(), add @tailwindcss/vite, set
BETTER_AUTH_SECRET), deploy.
If you fork / rename the package (it's currently emdash-better-auth):
the AuthProviderDescriptor and the settings-plugin descriptor in
src/index.ts reference the package by name in their route entrypoints and
adminEntry. Astro's injectRoute resolves those as module specifiers at the
consuming site's build time, so they must equal the package's own name. They
are all derived from a single PACKAGE_NAME constant at the top of
src/index.ts — update just that constant and the name in package.json
(keep them identical) and everything else follows.
Roadmap
Built on Better Auth, so the ecosystem is the runway. Planned / under consideration (tracked in Issues):
- Two-factor authentication — TOTP authenticator apps + email OTP, with backup codes (two-factor / email-otp plugins). Targeting v0.2.0.
- More social providers — Apple, Discord, Microsoft, GitLab, X (the provider list is data-driven; adding one is a two-line change).
- Magic-link sign-in.
- Organizations / teams and Stripe billing — larger, exploratory.
Have a request or want to help? Open an issue or PR.
License
MIT
