# AWS SNS Plugin (SMS OTP)

`@quickback-dev/better-auth-aws-sns` is the SMS transport package Quickback wires into Better Auth's built-in `phoneNumber` plugin when you opt in to SMS OTP. It signs SNS `Publish` requests with AWS Signature V4 using the Web Crypto API — zero AWS SDK dependency, runs on Cloudflare Workers.

> **Why SMS?** Corporate email gateways aggressively filter or rewrite transactional auth emails (link previews, click-tracking, MITM scanners). For some user bases SMS is the most reliable channel for a six-digit code that has to land in under a minute.

## What it provides

The package itself does **not** register a Better Auth plugin. Better Auth's builtin [`phoneNumber`](https://www.better-auth.com/docs/plugins/phone-number) plugin owns the endpoints (`/auth/v1/phone-number/send-otp`, `/verify`, `/sign-in/phone-number`, password reset). This package fills the `sendOTP` / `sendPasswordResetOTP` callbacks and the `phoneNumberValidator` hook.

| Export | Purpose |
|--------|---------|
| `sendSMSWithSNS(config, { to, message })` | Publish to SNS with SigV4 + in-memory rate limit + E.164 validation. |
| `smsTemplates.otp` / `smsTemplates.passwordReset` | Short transactional SMS bodies (single GSM-7 segment). |
| `isValidE164(phone)` / `normalizeE164(phone)` | Phone number validators. Wired into Better Auth's `phoneNumberValidator`. |

## Enable in config

```ts
import { defineAuth, defineConfig, defineDatabase, defineRuntime } from "@quickback/compiler";

export default defineConfig({
  name: "my-app",
  sms: {
    provider: "aws-sns",
    region: "us-east-1",
    senderId: "MyApp",          // optional — non-US countries only
    smsType: "Transactional",   // default
  },
  providers: {
    runtime: defineRuntime("cloudflare"),
    database: defineDatabase("cloudflare-d1"),
    auth: defineAuth("better-auth", {
      emailAndPassword: { enabled: true },
      plugins: { phoneNumber: true },
    }),
  },
});
```

The compiler:

1. Adds `@quickback-dev/better-auth-aws-sns` to the generated `package.json`.
2. Emits a `getSNSConfig(env)` helper in `src/lib/auth.ts`.
3. Wires the `phoneNumber` plugin with `sendOTP`, `sendPasswordResetOTP`, and `phoneNumberValidator: isValidE164`.
4. Drops `AWS_SNS_REGION` / `APP_NAME` (and `SMS_SENDER_ID` / `SMS_TYPE` when set) into `[vars]` in `wrangler.toml`.
5. Forwards `c.executionCtx` from the Hono handler so OTP sends use `waitUntil(...)` — Better Auth advises **not** awaiting `sendOTP` to keep SMS latency out of the response.

## What you get from Better Auth's `phoneNumber` plugin

Schema (added to your user table):

| Column | Type | Notes |
|--------|------|-------|
| `phoneNumber` | `string \| null` | E.164 format |
| `phoneNumberVerified` | `boolean` | Set on successful verify |

Endpoints:

| Method | Path | Purpose |
|--------|------|---------|
| `POST` | `/auth/v1/phone-number/send-otp` | Trigger an OTP SMS |
| `POST` | `/auth/v1/phone-number/verify` | Verify the OTP code |
| `POST` | `/auth/v1/sign-in/phone-number` | Phone + password sign-in |
| `POST` | `/auth/v1/phone-number/request-password-reset` | Send a reset OTP |
| `POST` | `/auth/v1/phone-number/reset-password` | Complete password reset |

Defaults: 6-digit codes, 300s expiry, 3 verify attempts (exceeding deletes the OTP and returns 403). See the [Better Auth plugin docs](https://www.better-auth.com/docs/plugins/phone-number) for the full option surface.

## Required environment variables

Wrangler `[vars]` (non-secret, emitted by the compiler):

| Name | Default | Notes |
|------|---------|-------|
| `AWS_SNS_REGION` | `us-east-1` | Any SNS-enabled region. |
| `APP_NAME` | `<your config.name>` | Appears in the SMS body. |
| `SMS_SENDER_ID` | — | Optional. Set only for non-US/CA traffic. |
| `SMS_TYPE` | `Transactional` | Override only to send promotional traffic. |

Secrets (set with `wrangler secret put`):

| Name | Required | Notes |
|------|----------|-------|
| `AWS_ACCESS_KEY_ID` | yes | IAM key with `sns:Publish`. Shared with SES if used. |
| `AWS_SECRET_ACCESS_KEY` | yes | Companion secret. |

## AWS prerequisites

Before SMS will actually deliver in production, do this in the AWS console:

1. **Pull out of the SNS sandbox.** New accounts can only send to verified numbers and have a low monthly cap. Submit a "Request production access" case from the SNS SMS Preferences page.
2. **Set a monthly spend cap.** SMS gets expensive fast — set a limit before you go live.
3. **US A2P (10DLC).** Long codes sending app-to-person SMS to US numbers must be registered with TCR (The Campaign Registry) via Pinpoint SMS Voice v2. Carrier filtering rejects unregistered A2P traffic. Allow several weeks for approval.
4. **Sender ID support.** Alphanumeric sender IDs work in many countries (UK, France, Germany, India, etc.) but **not** the US or Canada. Pick origination identities per country.
5. **IAM policy.** The access key needs `sns:Publish` on `*` plus `sns:SetSMSAttributes` if you want to manage the spend cap programmatically.

## Validation and rate limiting

- **E.164 only.** `isValidE164` rejects anything that's not `+[country code][7–14 digits]`. There is no automatic country-code inference — capture the country in your UI (e.g. `react-phone-number-input`).
- **Send-side rate limit.** The package's in-memory map caps sends per phone (hourly + daily) when `rateLimit` is set on the config. It's an isolate-local guard; for stricter limits put a KV-backed limiter in front of `/auth/v1/phone-number/send-otp`.
- **Verify-side rate limit.** Better Auth's `phoneNumber` plugin enforces 3 attempts before deleting the OTP. You can additionally tighten Quickback's per-route limit on `/auth/v1/phone-number/send-otp` via `auth.rateLimit.customRules`.

## Carrier filtering

Keep the SMS body short, transactional, and link-free. The default template is:

```
Your <AppName> code is 123456. Expires in 5 min. Don't share it.
```

— under 80 characters, no URL, no marketing copy. Carriers filter long messages, links, and brand-imitation prefixes aggressively. If you customize the body, keep it under one GSM-7 segment (160 chars) to avoid multi-segment billing.

## Verifying the wiring

After compile, check the generated `src/lib/auth.ts`:

```ts
import { sendSMSWithSNS, smsTemplates, isValidE164 } from "@quickback-dev/better-auth-aws-sns";

function getSNSConfig(env: CloudflareBindings | undefined): SNSConfig { /* ... */ }

function createAuth(env, cf, executionCtx) {
  const fireAndForget = (p) => { /* waitUntil(p) */ };

  return betterAuth({
    plugins: [
      phoneNumber({
        phoneNumberValidator: isValidE164,
        sendOTP: async ({ phoneNumber, code }) => {
          fireAndForget(sendSMSWithSNS(getSNSConfig(env), {
            to: phoneNumber,
            message: smsTemplates.otp.message({ code, appName: ... }),
          }));
        },
        // ...
      }),
    ],
  });
}
```

That's the contract: callbacks are fire-and-forget via `executionCtx.waitUntil`, never `await`ed.

## Sign-in flow

```ts
// 1. Request OTP
await fetch('/auth/v1/phone-number/send-otp', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ phoneNumber: '+14155550100' }),
});

// 2. Verify the code the user types in
const res = await fetch('/auth/v1/phone-number/verify', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ phoneNumber: '+14155550100', code: '123456' }),
});
```

For Better Auth's signed-in client SDK, use `authClient.phoneNumber.sendOtp(...)` / `authClient.phoneNumber.verify(...)`.

## Compliance reminders

- **TCPA (US).** Capture explicit opt-in for transactional SMS at signup; keep an audit trail. STOP / HELP keyword handling is mandatory for A2P traffic — SNS handles standard keywords automatically on registered origination numbers, but verify it's enabled.
- **GDPR (EU).** Phone numbers are personal data. Use Quickback's masking pillar to redact them in CMS views where appropriate.
- **Don't use SMS as your only second factor.** SIM-swap attacks are real; pair phone OTP with passkeys / TOTP for high-value accounts.
