Better Auth
Better Auth
ships no email transport. It generates a token and a URL, then calls a callback you
write — for magic links, OTPs, email verification, password resets, and organization
invitations. @mailkite/better-auth is a plugin that fills all five.
See it working: better-auth.mailkite.dev is a live Better Auth app using this plugin and its inbound counterpart — sign up, get a real verification email, sign in by magic link or one-time code, then claim an address and email it. Source: github.com/mailkite/better-auth-demo.
Install
npm install @mailkite/better-auth Set it up
Add the plugin, then hand its callbacks to the three plugins that take their own.
Verify a sending domain first — the from
address has to live on it.
import { betterAuth } from "better-auth";
import { magicLink, emailOTP, organization } from "better-auth/plugins";
import { mailkite } from "@mailkite/better-auth";
const mk = mailkite({
apiKey: process.env.MAILKITE_API_KEY, // required — no env fallback
from: "auth@acme.com", // on a domain verified for sending
appName: "Acme",
appUrl: "https://acme.com", // builds the invitation link
});
export const auth = betterAuth({
emailAndPassword: { enabled: true },
plugins: [
mk, // verification + reset, wired for you
magicLink({ sendMagicLink: mk.sendMagicLink }),
emailOTP({ sendVerificationOTP: mk.sendVerificationOTP }),
organization({ sendInvitationEmail: mk.sendInvitationEmail }),
],
}); That's the whole integration. There is no schema and no endpoint, so there is no migration to run — this is a transport, not a data model.
The plugin's init() supplies
emailVerification.sendVerificationEmail and
emailAndPassword.sendResetPassword. Better Auth merges that with
defu, so if you set either callback yourself, yours wins. The other three
are read from their own plugin's closure rather than from root options, so they stay
explicit. Installing the plugin never enables an auth method on its own.
Why sends are backgrounded
Better Auth's docs warn against awaiting the send: if the response is slower when the
account exists, the timing itself tells an attacker who has an account. So every
callback here dispatches and returns immediately. Failures go to
onError (default: console.error) and never to the caller — a
thrown error is the same side channel wearing a different hat.
On serverless, pass waitUntil or the runtime may reclaim the request
before the send finishes:
// Cloudflare Workers, or any runtime that reclaims the request after the response
const mk = mailkite({
apiKey: env.MAILKITE_API_KEY,
from: "auth@acme.com",
waitUntil: (p) => ctx.waitUntil(p),
});
If you would rather await — and accept the timing channel — set
awaitSend: true.
Branding
The built-in emails render under your product name and colours, not MailKite's. Covers magic link, OTP (with distinct copy for sign-in, verification and password reset), verification, reset, and invitations.
const mk = mailkite({
apiKey: env.MAILKITE_API_KEY,
from: "auth@acme.com",
appName: "Acme",
logoUrl: "https://acme.com/logo.png",
brandColor: "#7c3aed",
replyTo: "support@acme.com",
}); Using your own templates
Create a template in MailKite and map it per type. Unmapped types keep the built-ins.
const mk = mailkite({
apiKey: env.MAILKITE_API_KEY,
from: "auth@acme.com",
templates: {
magicLink: "tpl_abc123",
otp: "tpl_def456",
// verify, reset, invitation — unmapped types keep the built-ins
},
}); Merge tags passed to your template:
| Type | Tags |
|---|---|
magicLink | login_url, app_name |
verify | verify_url, name, app_name |
reset | reset_url, name, app_name |
otp | code, app_name |
invitation | invite_url, team, inviter, app_name |
Options
| Option | Notes |
|---|---|
from | Required. Address on a domain verified for sending (SPF + DKIM). |
apiKey | Required unless you pass getToken. There is no environment fallback — an implicit one looked configured and silently sent nothing on runtimes without process.env. |
getToken | Return a fresh Bearer token per send — use instead of apiKey for short-lived OAuth tokens. |
appName | Shown in the emails. Defaults to the from domain. |
appUrl | Builds the default invite link, {appUrl}/accept-invitation/{id}. |
logoUrl · brandColor | Branding. Colour defaults to #2f6fe0. |
replyTo | Applied to every message. |
invitationUrl | Build the invite URL yourself. Beats appUrl. |
templates | Per-type MailKite templateId overrides. |
waitUntil | Serverless keep-alive. |
onError | Send failures land here. Default logs. |
awaitSend | Await instead of backgrounding. Off by default. |
Receiving email
Auth email is one-way — every Better Auth callback is outbound. If you want the app to
receive mail too, that's MailKite inbound:
mail arrives at your webhook as parsed JSON, so replies to a notification, support
mail, or a per-organization inbox become ordinary requests.
app/api/inbound/route.ts // app/api/inbound/route.ts — replies land here as clean JSON
import { verifyWebhook } from "mailkite";
export async function POST(req: Request) {
const body = await req.text();
const event = verifyWebhook(body, req.headers, process.env.MAILKITE_WEBHOOK_SECRET);
// event.from, event.subject, event.text, event.html, event.attachments
return Response.json({ ok: true });
}
Pair it with Better Auth's organization plugin and each org gets its own
address. See Inbound webhooks and
Verifying signatures.
Security
- Every interpolated value — app name, user name, organization, inviter — is HTML-escaped. All of them are attacker-influenceable at signup.
- CTA URLs that aren't
http(s) render as #, so a poisoned callback URL can't become javascript:. - Send failures never reject to the caller, and no copy differs by whether the account exists.
Source
MIT, zero runtime dependencies:
github.com/mailkite/mailkite-better-auth
·
npm