Connect Better Auth to Email Bump for verification links, password resets, magic links, email OTPs, and organization invitations—with secure templates and production-safe delivery patterns.
Better Auth owns authentication state, tokens, verification rules, and the routes that consume those tokens. Email Bump owns the last mile: turning each authentication event into a branded transactional message and recording what happened after it was submitted. The connection between them is a small set of server-side callback functions.
This guide starts with email verification and password reset, then extends the same Email Bump adapter to magic links, email OTPs, and organization invitations. The examples use TypeScript and the Email Bump REST API, so they work in any Better Auth integration with a server-side fetch implementation.
What you will connect
AUTH FLOW BETTER AUTH HOOK EMAIL CONTENT
Email verification sendVerificationEmail verification URL
Password reset sendResetPassword password-reset URL
Magic-link sign-in sendMagicLink single-use sign-in URL
Email OTP sendVerificationOTP one-time code + purpose
Two-factor email OTP otpOptions.sendOTP one-time second factor
Organization invitation sendInvitationEmail invitation ID or app URL
Email change approval sendChangeEmailConfirmation approval URLYou only need to configure the hooks for features your application uses. Start with verification and password reset; add plugins without creating another provider integration by reusing the same transport and template boundary.
Create an Email Bump sender and API key
- Create an Email Bump project for the application environment.
- Verify a domain you control, then choose a stable From address such as Acme <auth@updates.acme.com>.
- Create a project-scoped API key and store it in the deployment platform’s secret manager.
- Use separate projects or keys for development, preview, and production.
- Send the first test only to an inbox you control, then inspect the transactional activity timeline.
BETTER_AUTH_SECRET=replace-with-a-long-random-secret
BETTER_AUTH_URL=http://localhost:3000
EMAIL_BUMP_API_KEY=ebk_replace_me
EMAIL_FROM="Acme <auth@updates.acme.com>"Keep EMAIL_BUMP_API_KEY server-only. Do not prefix it with NEXT_PUBLIC, VITE, PUBLIC, or any other build-system marker that exposes variables to browser code. BETTER_AUTH_URL should be the public origin Better Auth uses to build its endpoints; use HTTPS in production.
Build one Email Bump transport adapter
Put authentication email behind one function. That keeps the credential, endpoint, timeout, request shape, response parsing, and error policy out of the Better Auth configuration.
export type AuthEmail = {
purpose: string;
to: string;
subject: string;
html: string;
text: string;
};
function required(name: string): string {
const value = process.env[name]?.trim();
if (!value) throw new Error(`Missing environment variable: ${name}`);
return value;
}
export async function sendWithEmailBump(message: AuthEmail) {
const response = await fetch("https://emailbump.com/api/v1/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${required("EMAIL_BUMP_API_KEY")}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
from: required("EMAIL_FROM"),
to: message.to,
subject: message.subject,
html: message.html,
text: message.text,
}),
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) {
throw new Error(`Email Bump rejected ${message.purpose}: ${response.status}`);
}
return (await response.json()) as { id: string; message_id: string };
}
export function dispatchAuthEmail(message: AuthEmail) {
void sendWithEmailBump(message).catch(() => {
// Report the purpose and operation ID to your error tracker.
// Never log the action URL, OTP, API key, or full message body.
console.error("Authentication email failed", { purpose: message.purpose });
});
}Render a safe reusable authentication template
Authentication messages are small, but they still need an escaped HTML version, a useful plain-text alternative, a clear expiry statement, and copy for a recipient who did not request the action. Never place a raw token in logs, analytics parameters, image URLs, or third-party scripts.
export function escapeHtml(value: string) {
return value.replace(/[&<>"']/g, (character) => ({
"&": "&",
"<": "<",
">": ">",
'"': """,
"'": "'",
})[character]!);
}
type ActionEmail = {
preview: string;
heading: string;
copy: string;
action: string;
url: string;
ignore: string;
};
export function renderActionEmail(input: ActionEmail) {
const url = escapeHtml(input.url);
const html = `<!doctype html>
<html lang="en"><body style="font-family:Arial,sans-serif;color:#17382d">
<div style="display:none;max-height:0;overflow:hidden">${escapeHtml(input.preview)}</div>
<main style="max-width:560px;margin:40px auto;padding:32px">
<h1>${escapeHtml(input.heading)}</h1>
<p>${escapeHtml(input.copy)}</p>
<p><a href="${url}" style="display:inline-block;padding:12px 18px;background:#17382d;color:#fff;text-decoration:none">${escapeHtml(input.action)}</a></p>
<p>If the button does not work, copy this URL:<br><a href="${url}">${url}</a></p>
<p style="color:#62736b;font-size:13px">${escapeHtml(input.ignore)}</p>
</main>
</body></html>`;
const text = `${input.heading}
${input.copy}
${input.action}: ${input.url}
${input.ignore}`;
return { html, text };
}Configure verification and password-reset email
Better Auth supplies a complete action URL to each callback. Send that URL unchanged instead of rebuilding it from the token. This preserves Better Auth’s configured base path, callback URL, and token-consumption route.
import { betterAuth } from "better-auth";
import { dispatchAuthEmail } from "./email-bump";
import { renderActionEmail } from "./auth-email-template";
export const auth = betterAuth({
emailAndPassword: {
enabled: true,
requireEmailVerification: true,
async sendResetPassword({ user, url }) {
const body = renderActionEmail({
preview: "Reset your Acme password",
heading: "Reset your password",
copy: "Use this secure link to choose a new password.",
action: "Reset password",
url,
ignore: "If you did not request a reset, you can ignore this email.",
});
dispatchAuthEmail({
purpose: "password-reset",
to: user.email,
subject: "Reset your Acme password",
...body,
});
},
},
emailVerification: {
sendOnSignUp: true,
sendOnSignIn: true,
expiresIn: 60 * 60,
async sendVerificationEmail({ user, url }) {
const body = renderActionEmail({
preview: "Verify your email to finish setting up Acme",
heading: "Verify your email",
copy: "Confirm this address to finish setting up your account.",
action: "Verify email",
url,
ignore: "If you did not create an Acme account, you can ignore this email.",
});
dispatchAuthEmail({
purpose: "email-verification",
to: user.email,
subject: "Verify your email for Acme",
...body,
});
},
},
});sendOnSignUp sends verification during registration. requireEmailVerification prevents email-and-password sign-in until the address is verified, while sendOnSignIn sends another verification message when an unverified user tries again. Better Auth’s social-provider behavior is separate, so test every enabled sign-in method instead of assuming one setting gates all of them.
Trigger verification from the client
import { authClient } from "@/lib/auth-client";
const { error } = await authClient.sendVerificationEmail({
email: "person@example.com",
callbackURL: "/dashboard",
});
if (error) {
// Keep the UI response neutral; do not reveal account existence.
showMessage("If that address can be verified, an email is on the way.");
}Apply a resend cooldown and rate limits by address, account, IP, and device signal. Show a neutral response for verification and password-recovery requests. A generic UI response reduces account enumeration; it does not replace server-side throttling or abuse monitoring.
Do not lose auth email on serverless platforms
Better Auth recommends not awaiting provider delivery because provider latency can create timing differences between account states. A detached promise is acceptable only when the runtime keeps the process alive. Many serverless runtimes may freeze or terminate work after the response finishes.
- On a persistent Node.js server, dispatch the promise and report failures explicitly.
- On a serverless platform, register the promise with the platform’s waitUntil or equivalent lifecycle API.
- For the strongest reliability, enqueue a small auth-email job durably and let a worker call Email Bump.
- Keep the public response neutral and fast regardless of whether the user exists or the provider is slow.
- Expire queued work before its link or OTP becomes useless; stale auth email should be cancelled, not delivered late.
Add magic-link sign-in
import { betterAuth } from "better-auth";
import { magicLink } from "better-auth/plugins";
export const auth = betterAuth({
plugins: [
magicLink({
expiresIn: 5 * 60,
async sendMagicLink({ email, url }) {
const body = renderActionEmail({
preview: "Your secure sign-in link for Acme",
heading: "Sign in to Acme",
copy: "This link signs you in and expires in five minutes.",
action: "Sign in",
url,
ignore: "If you did not request this link, you can ignore this email.",
});
dispatchAuthEmail({
purpose: "magic-link",
to: email,
subject: "Your Acme sign-in link",
...body,
});
},
}),
],
});A magic link is a credential. Do not prefetch it in your own application, route it through a click tracker, or expose it to analytics. Keep links single-purpose, short-lived, HTTPS-only, and associated with the intended callback policy. Leave click tracking disabled for authentication mail; Email Bump can still record submission and delivery events without rewriting the action URL.
Add email OTPs
The emailOTP plugin calls one hook for sign-in, email verification, and password recovery. Map the Better Auth type to explicit subject and copy; do not send a generic code without telling the recipient what it authorizes.
import { emailOTP } from "better-auth/plugins";
import { escapeHtml } from "./auth-email-template";
const otpCopy = {
"sign-in": ["Your Acme sign-in code", "Use this code to sign in"],
"email-verification": ["Verify your Acme email", "Use this code to verify your email"],
"forget-password": ["Reset your Acme password", "Use this code to reset your password"],
} as const;
export const auth = betterAuth({
plugins: [
emailOTP({
expiresIn: 5 * 60,
allowedAttempts: 3,
async sendVerificationOTP({ email, otp, type }) {
const [subject, instruction] = otpCopy[type];
const safeOtp = escapeHtml(otp);
dispatchAuthEmail({
purpose: `email-otp:${type}`,
to: email,
subject,
html: `<h1>${subject}</h1><p>${instruction}:</p><p style="font-size:32px;letter-spacing:6px"><strong>${safeOtp}</strong></p><p>This code expires in five minutes. Do not share it.</p>`,
text: `${subject}
${instruction}: ${otp}
This code expires in five minutes. Do not share it.`,
});
},
}),
],
});Rate-limit both code issuance and verification attempts. Better Auth’s allowedAttempts option limits guesses against one OTP; application-level controls still need to constrain repeated code generation. Decide whether a resend rotates or reuses an active code and make the email copy match that behavior.
Send organization invitations through the same adapter
import { organization } from "better-auth/plugins";
organization({
requireEmailVerificationOnInvitation: true,
async sendInvitationEmail(data) {
const baseURL = process.env.BETTER_AUTH_URL;
if (!baseURL) throw new Error("BETTER_AUTH_URL is required");
const url = new URL("/accept-invitation", baseURL);
url.searchParams.set("invitationId", data.id);
const organizationName = data.organization.name.replace(/[\r\n]+/g, " ").trim();
const body = renderActionEmail({
preview: `${data.inviter.user.name} invited you to ${organizationName}`,
heading: `Join ${organizationName}`,
copy: `${data.inviter.user.name} invited you to their organization on Acme.`,
action: "Review invitation",
url: url.toString(),
ignore: "If you were not expecting this invitation, you can ignore it.",
});
dispatchAuthEmail({
purpose: "organization-invitation",
to: data.email,
subject: `You’re invited to ${organizationName}`,
...body,
});
},
});The invitation landing page should require the recipient to sign in before it calls Better Auth’s acceptInvitation method. Use opaque invitation IDs and verify that the session email matches the invitation. Requiring verified email adds a stronger ownership check when membership is sensitive or invitation identifiers may be exposed elsewhere.
Authentication email security checklist
- Keep Email Bump credentials and all send functions on the server.
- Use only Better Auth-generated action URLs and validate your configured public origin.
- Do not add action URLs, tokens, or OTPs to logs, analytics, tracking redirects, or error reports.
- Escape names, organization names, and all other untrusted values in HTML and subject lines.
- Use HTTPS, short expirations, bounded attempts, resend cooldowns, and neutral recovery responses.
- Separate authentication messages from promotional content and marketing suppression rules.
- Disable click tracking for authentication mail so action URLs are not rewritten.
- Keep HTML and plain-text alternatives, and state what the action does and when it expires.
- Treat provider acceptance, delivery, and successful token consumption as separate events.
- Alert on auth-email failures and latency without storing message secrets in telemetry.
Test the full flow
CONFIGURATION
missing API key · unverified From domain · wrong production origin
VERIFICATION
sign-up send · sign-in resend · expired link · already-used link · callback redirect
PASSWORD RESET
known address · unknown address with neutral response · expired link · password changed
MAGIC LINK / OTP
single-use link · resend behavior · expired code · attempt limit · rate limit
DELIVERY
provider 400 · provider 429 · provider 500 · timeout after possible acceptance
serverless response completion · durable-job expiry · duplicate request
CONTENT
escaped name and organization · valid HTTPS action URL · useful plain text
mobile layout · no secret in logs or tracked linksMock sendWithEmailBump in unit tests and assert the recipient, purpose, subject, and action destination. Use a restricted development key for one end-to-end send. Then test the real link or code through Better Auth and confirm the Email Bump timeline records acceptance and delivery without exposing the credential in observability data.
Production checklist
- Verify the production sending domain and create a least-privilege project key.
- Validate BETTER_AUTH_URL, BETTER_AUTH_SECRET, EMAIL_FROM, and EMAIL_BUMP_API_KEY at startup.
- Centralize Email Bump submission and keep it out of browser bundles.
- Configure only the Better Auth hooks and plugins the product actually uses.
- Choose a runtime-safe background strategy; do not detach work on serverless without waitUntil.
- Make auth jobs expire before their URL or OTP and suppress stale queued messages.
- Rate-limit requests and resends while preserving neutral account-existence responses.
- Test link consumption, expiry, replay, callbacks, provider failures, and message rendering.
- Monitor provider acceptance and delivery separately from successful authentication.