# Better Auth

Source: https://wraps.dev/docs/guides/better-auth

`@wraps.dev/better-auth` sends every auth email Better Auth leaves to you through SES in your own AWS account, from one plugin block: verification, password reset, password changed, magic link, OTP, and organization invites. The templates ship with it and carry your branding, not ours. Sending runs on your AWS credentials alone, so no Wraps account is required.

One plugin block in `auth.ts` gives Better Auth senders for all six auth emails. Each one is delivered by SES in your own AWS account, from your verified domain. Syncing the signup to Wraps Contacts is optional, and the only part that needs a Wraps API key.

Different from the other providers on that list

Resend and Mailtrap hand you an API key and send from their infrastructure. This plugin sends from yours, so the domain, the reputation, and the AWS bill stay in your account. Most people reach for an API key because setting SES up is the annoying part. The CLI below does it for you. The prerequisite is still real: an AWS account and a verified domain before the first email leaves. If you do not have an AWS account and do not want one, use a hosted API.

Run this quickstart with your AI agent

Paste into Claude Code, Cursor, or any agent with shell access.

Copy promptView

Using Claude Code? Install the Wraps skills for deeper context:

npx add-skill wraps-team/skills

## Prerequisites

Before You Start: AWS Credentials Required

Every command below runs against your AWS account. Before running anything, make sure you have:

-   Node.js 20 or later installed
-   AWS credentials available to the CLI — `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` environment variables, a configured profile via `aws configure`, or an active `aws sso login` session
-   Better Auth 1.6 or later already installed and configured
-   A sending domain verified in SES, with DKIM records published
-   SES production access on the account, or a plan to request it

terminal.sh

```
# Option A: environment variablesexport AWS_ACCESS_KEY_ID=AKIA...export AWS_SECRET_ACCESS_KEY=...export AWS_REGION=us-east-1# Option B: AWS CLI profileaws configure# Option C: AWS SSOaws sso login
```

Missing credentials fail after the first prompt

If credentials aren't configured, the CLI still starts and asks its telemetry question before failing with a credentials error. Set up credentials first to avoid the confusing dead end.

If the domain is not verified yet, the CLI does it without a trip through the console.

terminal.sh

```
npx @wraps.dev/cli email domains add -d acme.comnpx @wraps.dev/cli email domains verify -d acme.com
```

A new AWS account cannot send auth emails yet

Every SES account starts in the sandbox, where sends to addresses you have not individually verified fail with `MessageRejected: Email address is not verified`. Signup verification is the worst case for this, because the recipient is by definition a stranger. Request production access before launch. AWS usually answers within 24 hours, and the decision is theirs. No provider, including Wraps, can grant it for you. [Production access guide](https://wraps.dev/docs/guides/production-access).

## Installation

npmpnpmyarnbun

npm install @wraps.dev/better-auth @wraps.dev/email

`@wraps.dev/email` is an optional peer dependency. The plugin imports it lazily, so a project using only contact sync never pulls the AWS SDK into its bundle. If you set `email` without installing it, the first send fails at import time and the error arrives in `onError`.

## Send the Verification Email

That is the whole setup. There is no API key in it.

auth.ts

```
import { betterAuth } from 'better-auth';import { wraps } from '@wraps.dev/better-auth';export const auth = betterAuth({  emailAndPassword: { enabled: true },  emailVerification: { sendOnSignUp: true },  plugins: [    wraps({      email: {        from: 'Acme <auth@acme.com>',        appName: 'Acme',        appUrl: 'https://app.acme.com',      },    }),  ],});
```

Three senders are now wired: `sendVerificationEmail`, `sendResetPassword`, and `onPasswordReset`. The bundled templates are plain HTML with no React dependency and no Wraps branding, so they read as coming from your app.

Your config always wins

Better Auth merges plugin options underneath your own. If you already define `sendVerificationEmail`, the plugin leaves it alone, because the senders it supplies are defaults that fill gaps. It also never sets `emailAndPassword.enabled`, so setting `email` cannot switch on password auth for an app that did not ask for it.

## The Other Auth Emails

Magic link, OTP, and organization invites belong to other Better Auth plugins, so Wraps cannot reach them from its own config. Build the senders once and pass them in.

auth.ts

```
import { wrapsAuthEmails } from '@wraps.dev/better-auth';import { emailOTP, magicLink, organization } from 'better-auth/plugins';const emails = wrapsAuthEmails({  from: 'Acme <auth@acme.com>',  appName: 'Acme',  appUrl: 'https://app.acme.com', // builds the invitation link});export const auth = betterAuth({  plugins: [    magicLink({ sendMagicLink: emails.magicLink }),    emailOTP({ sendVerificationOTP: emails.otp }),    organization({ sendInvitationEmail: emails.invitation }),  ],});
```

| Sender | Plugs into | Setup |
| --- | --- | --- |
| `verification` | `emailVerification.sendVerificationEmail` | Wired for you |
| `resetPassword` | `emailAndPassword.sendResetPassword` | Wired for you |
| `passwordChanged` | `emailAndPassword.onPasswordReset` | Wired for you |
| `magicLink` | `magicLink({ sendMagicLink })` | Pass it in |
| `otp` | `emailOTP({ sendVerificationOTP })` | Pass it in |
| `invitation` | `organization({ sendInvitationEmail })` | Pass it in |

Invitations need a link. Set `appUrl` and the plugin builds `/accept-invitation/:id` under it, or set `invitationUrl` to build your own. With neither, the invitation is not sent, because an invitation carrying a dead link is worse than one that never arrived.

## AWS Credentials

Credentials follow the standard `@wraps.dev/email` resolution chain. With nothing set, the AWS chain resolves as usual: environment variables, shared config, or an instance role. On Vercel or GitHub Actions, assume a role over OIDC instead of storing long-lived keys.

auth.ts

```
wraps({  email: {    from: 'auth@acme.com',    ses: {      region: 'us-east-1',      // OIDC role assumption on Vercel or GitHub Actions      roleArn: 'arn:aws:iam::123456789012:role/AcmeMail',    },  },});
```

The [Vercel setup guide](https://wraps.dev/docs/guides/vercel-setup) covers the trust policy and the IAM permissions the role needs.

## Branding and Templates

Brand tokens apply to every bundled template, which covers most apps without writing any markup.

auth.ts

```
wraps({  email: {    from: 'Acme <auth@acme.com>',    appName: 'Acme',    brand: {      logoUrl: 'https://acme.com/logo.png',      primaryColor: '#4f46e5',      supportEmail: 'help@acme.com',      footerText: 'Acme Inc, 2500 Larimer St, Denver CO',    },  },});
```

When you want the markup itself, override any template. It receives the same inputs the bundled one gets and returns a subject, HTML, and text.

auth.ts

```
wraps({  email: {    from: 'auth@acme.com',    templates: {      verification: ({ user, url, appName }) => ({        subject: `Confirm your ${appName} account`,        html: renderMyEmail({ user, url }),        text: `Confirm your email: ${url}`,      }),    },  },});
```

## When a Send Fails

Every send is wrapped, so an SES throttle cannot break a signup. That protection cuts both ways: a failed send does not surface as a thrown error either. Wire `onError` and a dead verification email shows up in your logs instead of in a support ticket.

auth.ts

```
wraps({  email: { from: 'auth@acme.com', appName: 'Acme' },  onError: (error, { stage, user }) => {    // stage is 'email' | 'contact' | 'event' | 'attribution'    logger.warn({ err: error, stage, user: user?.email }, 'auth email failed');  },});
```

With no handler set, failures go to `console.error` rather than being dropped silently.

## Serverless and waitUntil

Work is awaited by default, which is the safe choice on Lambda: the runtime freezes the moment the handler returns, so fire-and-forget background work never happens. Pass `waitUntil` only when your platform has a real background primitive.

auth.ts

```
import { waitUntil } from '@vercel/functions';wraps({  apiKey: process.env.WRAPS_API_KEY,  waitUntil,});
```

## Troubleshooting

| Symptom | Cause and fix |
| --- | --- |
| `MessageRejected: Email address is not verified` | The account is still in the SES sandbox, or the `from` domain is not verified. Verify the domain, then request production access. |
| Nothing arrives and nothing throws | Sends never throw into auth. Set `onError`, or read `console.error`. The real reason is already being reported. |
| `Cannot find package '@wraps.dev/email'` | The email peer dependency is not installed. Add `@wraps.dev/email`. |
| Invitations never send, `onError` fires with `stage: 'email'` | No invitation link could be built. Set `appUrl` or `invitationUrl`. |
| Works locally, silent in production | A `waitUntil` that is not a real background primitive lets the function freeze mid-write. Remove it and let the plugin await. |

## Sync Signups to Wraps Contacts (Optional)

The second half of the plugin turns a new user into a Wraps contact and fires a `user.signed_up` event, so a welcome sequence or an onboarding workflow can run off the signup. This half needs a Wraps API key. Contacts live in the Wraps database; sending still runs through your SES.

auth.ts

```
wraps({  apiKey: process.env.WRAPS_API_KEY,  attribution: true, // read utm_source and friends off the signup request  email: {    from: 'Acme <auth@acme.com>',    appName: 'Acme',  },});
```

Two requests go out on user creation.

requests.http

```
POST /v1/contacts/{  "externalId": "K3mQx...",      // the better-auth user id  "email": "ada@example.com",  "firstName": "Ada",  "lastName": "Lovelace",  "emailStatus": "active"}POST /v1/events/{  "name": "user.signed_up",  "contactId": "con_...",  "properties": { "method": "oauth", "provider": "google", "source": "better-auth" }}
```

If the email already belongs to a contact — a newsletter subscriber converting, say — that contact is patched instead of failing. `properties.method` records how they signed up: `email`, `oauth`, `passkey`, `magic-link`, or `otp`. Every creation path is covered, including OAuth: the plugin hangs off `databaseHooks.user.create.after` rather than response-level `after` hooks, which Better Auth skips on OAuth redirects.

### Consent and topics

New contacts are subscribed to no topics by default. A signup is a transactional relationship, not marketing consent, and quietly adding every new account to a marketing list is how SES reputations get damaged.

auth.ts

```
wraps({  apiKey: process.env.WRAPS_API_KEY,  // Only set this when your signup form actually asks for consent.  topicSlugs: ['product-updates'],});
```

### Client plugin

Type inference only. Everything happens server-side and your API key never reaches the browser.

auth-client.ts

```
import { createAuthClient } from 'better-auth/client';import { wrapsClient } from '@wraps.dev/better-auth/client';export const authClient = createAuthClient({  plugins: [wrapsClient()],});
```

## Options

auth.ts

```
wraps({  // --- auth emails (no Wraps account required) ---  email: {    from: 'Acme <auth@acme.com>',    appName: 'Acme',    appUrl: 'https://app.acme.com',    invitationUrl: ({ id }) => `https://app.acme.com/join/${id}`,    replyTo: 'support@acme.com',    configurationSetName: 'acme-auth',    brand: { logoUrl, primaryColor, supportEmail, footerText },    templates: { /* per-template overrides */ },    ses: { /* region, credentials, roleArn, client */ },  },  // --- contact sync (needs a Wraps API key) ---  apiKey: process.env.WRAPS_API_KEY,  baseUrl: 'https://api.wraps.dev',  eventName: 'user.signed_up',        // or false to skip the event  topicSlugs: [],  emailStatus: 'active',  attribution: false,                 // true, or { cookieName, fields, fromReferer, parse }  properties: (user, context) => ({ plan: 'free' }),  shouldSync: (user, context) => !user.email.endsWith('@internal.acme.com'),  syncOnUpdate: true,                 // patch the contact on email or name change  syncOnDelete: false,                // or 'unsubscribe' | 'delete'  // --- behaviour ---  waitUntil: (promise) => ctx.waitUntil(promise),  onContactSynced: ({ userId, contactId, created }) => {},  onError: (error, { stage }) => logger.warn({ error, stage }),});
```

## Next Steps

Verify your domain

DKIM, SPF, and DMARC for the domain your auth emails send from. Do this before launch, not after the first spam complaint.

[Verify a domain](https://wraps.dev/docs/guides/domain-verification)

Leave the SES sandbox

What AWS wants in a production access request, and what gets an account denied. Verification emails do not work until this is done.

[Request access](https://wraps.dev/docs/guides/production-access)

### Need Help?

The plugin is MIT licensed and developed in the open. Open an issue with your Better Auth version and the `onError` stage you saw.

[Get Help](https://github.com/wraps-team/better-auth-wraps/issues)
