Quickback Docs
Plugins

AWS SNS Plugin (SMS OTP)

@quickback-dev/better-auth-aws-sns — AWS SNS SMS transport for Better Auth's phoneNumber plugin.

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

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

Enable in config

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):

ColumnTypeNotes
phoneNumberstring | nullE.164 format
phoneNumberVerifiedbooleanSet on successful verify

Endpoints:

MethodPathPurpose
POST/auth/v1/phone-number/send-otpTrigger an OTP SMS
POST/auth/v1/phone-number/verifyVerify the OTP code
POST/auth/v1/sign-in/phone-numberPhone + password sign-in
POST/auth/v1/phone-number/request-password-resetSend a reset OTP
POST/auth/v1/phone-number/reset-passwordComplete password reset

Defaults: 6-digit codes, 300s expiry, 3 verify attempts (exceeding deletes the OTP and returns 403). See the Better Auth plugin docs for the full option surface.

Required environment variables

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

NameDefaultNotes
AWS_SNS_REGIONus-east-1Any SNS-enabled region.
APP_NAME<your config.name>Appears in the SMS body.
SMS_SENDER_IDOptional. Set only for non-US/CA traffic.
SMS_TYPETransactionalOverride only to send promotional traffic.

Secrets (set with wrangler secret put):

NameRequiredNotes
AWS_ACCESS_KEY_IDyesIAM key with sns:Publish. Shared with SES if used.
AWS_SECRET_ACCESS_KEYyesCompanion 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:

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

Sign-in flow

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

On this page