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.
| 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
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:
- Adds
@quickback-dev/better-auth-aws-snsto the generatedpackage.json. - Emits a
getSNSConfig(env)helper insrc/lib/auth.ts. - Wires the
phoneNumberplugin withsendOTP,sendPasswordResetOTP, andphoneNumberValidator: isValidE164. - Drops
AWS_SNS_REGION/APP_NAME(andSMS_SENDER_ID/SMS_TYPEwhen set) into[vars]inwrangler.toml. - Forwards
c.executionCtxfrom the Hono handler so OTP sends usewaitUntil(...)— Better Auth advises not awaitingsendOTPto 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 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:
- 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.
- Set a monthly spend cap. SMS gets expensive fast — set a limit before you go live.
- 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.
- 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.
- IAM policy. The access key needs
sns:Publishon*plussns:SetSMSAttributesif you want to manage the spend cap programmatically.
Validation and rate limiting
- E.164 only.
isValidE164rejects 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
rateLimitis 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
phoneNumberplugin enforces 3 attempts before deleting the OTP. You can additionally tighten Quickback's per-route limit on/auth/v1/phone-number/send-otpviaauth.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.