Get your API key
Integrations

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

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.

lib/auth.ts
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:

edge runtime
// 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.

branding
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.

template overrides
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:

TypeTags
magicLinklogin_url, app_name
verifyverify_url, name, app_name
resetreset_url, name, app_name
otpcode, app_name
invitationinvite_url, team, inviter, app_name

Options

OptionNotes
fromRequired. Address on a domain verified for sending (SPF + DKIM).
apiKeyRequired unless you pass getToken. There is no environment fallback — an implicit one looked configured and silently sent nothing on runtimes without process.env.
getTokenReturn a fresh Bearer token per send — use instead of apiKey for short-lived OAuth tokens.
appNameShown in the emails. Defaults to the from domain.
appUrlBuilds the default invite link, {appUrl}/accept-invitation/{id}.
logoUrl · brandColorBranding. Colour defaults to #2f6fe0.
replyToApplied to every message.
invitationUrlBuild the invite URL yourself. Beats appUrl.
templatesPer-type MailKite templateId overrides.
waitUntilServerless keep-alive.
onErrorSend failures land here. Default logs.
awaitSendAwait 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