# AWS SNS Plugin (SMS OTP)

The AWS SNS transport is what Quickback wires into Better Auth's built-in `phoneNumber` plugin when you opt in to SMS OTP. The compiler emits its source into your project at `src/plugins/aws-sns/` — it is not an npm dependency. 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 transport 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. Emits the plugin source into `src/plugins/aws-sns/` — vendored, not an npm dependency; upgrades arrive by recompiling.
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 "../plugins/aws-sns";

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

function createAuth(env, cf, executionCtx) {
  const scheduleCredentialDelivery = (delivery) => {
    const observed = delivery.catch(() => {
      console.error(JSON.stringify({
        level: "error",
        event: "auth.credential_delivery_failed",
      }));
      throw new Error("Auth credential delivery failed");
    });
    if (executionCtx) {
      executionCtx.waitUntil(observed);
      return Promise.resolve();
    }
    return observed;
  };

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

That's the contract: Workers callbacks return after `waitUntil` accepts an
observed, still-rejecting task, so provider latency stays out of the response.
Without an execution context, the callback returns the observed promise for the
caller to await. Failures are logged generically without provider error content
and are never swallowed.

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