# Self-hosted Supabase — Send SMS auth hook (Lambda)

Wire **GoTrue** on your self-hosted Supabase instance to an **existing AWS Lambda** that sends SMS OTP messages. Self-hosted has **no Auth Hooks UI** — configuration is via `.env` + `docker-compose.yml`.

> **Related:** `docs/supabase-aws-rds-proxy-setup.md`, `docs/connect-private-ec2-ssm.md`, [Supabase Send SMS hook](https://supabase.com/docs/guides/auth/auth-hooks/send-sms-hook)

---

## Architecture

```text
User requests OTP (phone sign-in)
  → Kong /auth/v1/*
    → GoTrue (supabase-auth)
      → HTTP Send SMS hook
        → API Gateway (public)
          → Lambda (your SMS sender)
            → SMS provider (SNS, Pinpoint, Twilio, etc.)
```

GoTrue POSTs a **Standard Webhooks**–signed JSON payload. Lambda must verify the signature, read `user.phone` + `sms.otp`, send the message, and return **HTTP 200** with `{}`.

---

## Production values (Datafy)

| Setting | Value |
|---------|-------|
| Self-hosted API | `https://supabase.datafy.co.za` |
| Supabase project path | `/var/snap/amazon-ssm-agent/13009/supabase-project` |
| Region | `eu-west-1` |
| EC2 (Supabase) | Private subnet — needs **NAT** for outbound HTTPS to API Gateway |

Replace placeholder URLs below with your real API Gateway invoke URL.

---

## Prerequisites

- [ ] Self-hosted Supabase stack running (`auth` container healthy)
- [ ] **Phone auth** will be used (`GOTRUE_EXTERNAL_PHONE_ENABLED=true`)
- [ ] Lambda that sends SMS already exists (or you deploy the adapter below)
- [ ] **API Gateway** HTTP API or REST API in front of Lambda (HTTPS)
- [ ] EC2 Supabase instance can reach the internet (NAT gateway) — auth container calls API Gateway over HTTPS
- [ ] Shared **webhook secret** known to GoTrue and Lambda (see Step 1)

---

## Step 1 — Generate the webhook secret

GoTrue and Lambda must share the same secret in **Standard Webhooks** format:

```bash
# Generate 24+ random bytes, base64-encoded
SECRET=$(openssl rand -base64 24 | tr -d '\n')
echo "v1,whsec_${SECRET}"
```

Save the full string, e.g.:

```text
v1,whsec_K7gNU3sdo+OL0wNhqoVWhr3g6s1xYv72ol/pe/Unols=
```

You will set this in:

- `.env` → `GOTRUE_HOOK_SEND_SMS_SECRETS`
- Lambda env → `SEND_SMS_HOOK_SECRETS` (same value)

---

## Step 2 — Lambda handler (Supabase Send SMS hook)

If your Lambda already sends SMS but expects a **different JSON shape**, add verification + mapping at the top of the handler (or deploy a thin **adapter** Lambda that calls your existing one).

### Expected hook payload (from GoTrue)

```json
{
  "user": {
    "id": "6481a5c1-...",
    "phone": "+27821234567",
    "..."
  },
  "sms": {
    "otp": "561166"
  }
}
```

### Required HTTP headers (verify before trusting body)

| Header | Purpose |
|--------|---------|
| `webhook-id` | Unique delivery id |
| `webhook-timestamp` | Unix timestamp (seconds) |
| `webhook-signature` | HMAC signature |

### Success / error responses

| Result | Status | Body |
|--------|--------|------|
| SMS sent | **200** | `{}` |
| Business failure (provider error) | **4xx/5xx** | `{"error":{"message":"...","http_code":500}}` |
| Retry-able overload | **429** or **503** | + non-empty `retry-after` header |

GoTrue allows **~5 seconds** total including retries for HTTP hooks.

### Example adapter (`lambda/send_sms_auth_hook/app.py`)

Use this if you need a new function, or merge into your existing SMS Lambda:

```python
import json
import os
import base64
import hmac
import hashlib
import time
import boto3  # if using SNS — adjust for your provider

SEND_SMS_HOOK_SECRETS = os.environ.get("SEND_SMS_HOOK_SECRETS", "")
# Strip prefix for HMAC key (Standard Webhooks)
WEBHOOK_SECRET = SEND_SMS_HOOK_SECRETS.replace("v1,whsec_", "")

def verify_webhook(raw_body: str, headers: dict) -> dict:
    """Verify Standard Webhooks signature. Raises ValueError on failure."""
    msg_id = headers.get("webhook-id") or headers.get("Webhook-Id")
    timestamp = headers.get("webhook-timestamp") or headers.get("Webhook-Timestamp")
    signature = headers.get("webhook-signature") or headers.get("Webhook-Signature")
    if not all([msg_id, timestamp, signature]):
        raise ValueError("Missing webhook headers")

    # Reject stale requests (5 minute window)
    if abs(int(time.time()) - int(timestamp)) > 300:
        raise ValueError("Webhook timestamp too old")

    # Standard Webhooks signed content: "{id}.{timestamp}.{body}"
    signed_content = f"{msg_id}.{timestamp}.{raw_body}".encode()
    key = base64.b64decode(WEBHOOK_SECRET)

    # signature header can be "v1,<sig>" or multiple space-separated versions
    for part in signature.split(" "):
        if part.startswith("v1,"):
            expected = base64.b64decode(part[3:])
            if hmac.compare_digest(hmac.new(key, signed_content, hashlib.sha256).digest(), expected):
                return json.loads(raw_body)
    raise ValueError("Invalid webhook signature")


def send_sms(phone: str, otp: str) -> None:
    """
    TODO: Replace with your existing SMS logic.
    Example below uses SNS — swap for Pinpoint, Twilio, etc.
    """
    sns = boto3.client("sns", region_name=os.environ.get("AWS_REGION", "eu-west-1"))
    message = f"Your Datafy verification code is: {otp}"
    sns.publish(PhoneNumber=phone, Message=message)


def lambda_handler(event, context):
    try:
        raw_body = event.get("body") or ""
        if event.get("isBase64Encoded"):
            raw_body = base64.b64decode(raw_body).decode()

        headers = {k.lower(): v for k, v in (event.get("headers") or {}).items()}
        payload = verify_webhook(raw_body, headers)

        phone = payload["user"]["phone"]
        otp = payload["sms"]["otp"]
        send_sms(phone, otp)

        return {
            "statusCode": 200,
            "headers": {"Content-Type": "application/json"},
            "body": json.dumps({}),
        }
    except ValueError as e:
        return {
            "statusCode": 401,
            "headers": {"Content-Type": "application/json"},
            "body": json.dumps({"error": {"message": str(e), "http_code": 401}}),
        }
    except Exception as e:
        return {
            "statusCode": 500,
            "headers": {"Content-Type": "application/json"},
            "body": json.dumps({"error": {"message": str(e), "http_code": 500}}),
        }
```

### SAM / API Gateway (minimal)

Add to your SAM template (adjust names):

```yaml
SendSmsAuthHookFunction:
  Type: AWS::Serverless::Function
  Properties:
    CodeUri: send_sms_auth_hook/
    Handler: app.lambda_handler
    Runtime: python3.12
    Timeout: 10
    MemorySize: 256
    Environment:
      Variables:
        SEND_SMS_HOOK_SECRETS: !Ref SendSmsHookSecret
    Events:
      HookApi:
        Type: Api
        Properties:
          Path: /auth/send-sms
          Method: post

SendSmsHookSecret:
  Type: AWS::SSM::Parameter::Value<String>
  Default: /supabase/hooks/send_sms_secret
```

Store the secret in SSM:

```bash
aws ssm put-parameter \
  --name /supabase/hooks/send_sms_secret \
  --value 'v1,whsec_YOUR_SECRET_HERE' \
  --type SecureString \
  --overwrite \
  --region eu-west-1
```

After deploy, note the invoke URL, e.g.:

```text
https://abc123.execute-api.eu-west-1.amazonaws.com/prod/auth/send-sms
```

**Security:** Restrict API Gateway with a resource policy or API key if the URL is public. The webhook signature is the primary guard — still avoid exposing unnecessary endpoints.

---

## Step 3 — Enable phone auth + Send SMS hook on self-hosted

SSH/SSM to the Supabase EC2 instance.

### 3.1 Edit `.env`

```bash
cd /var/snap/amazon-ssm-agent/13009/supabase-project
sudo nano .env
```

Add or update:

```env
############################################################
# PHONE AUTH
############################################################
GOTRUE_EXTERNAL_PHONE_ENABLED=true
GOTRUE_SMS_AUTOCONFIRM=false
# Optional: message template vars — OTP is sent by your Lambda
# GOTRUE_SMS_TEMPLATE=Your code is {{ .Code }}

############################################################
# SEND SMS HOOK → Lambda via API Gateway
############################################################
GOTRUE_HOOK_SEND_SMS_ENABLED=true
GOTRUE_HOOK_SEND_SMS_URI=https://YOUR_API_ID.execute-api.eu-west-1.amazonaws.com/prod/auth/send-sms
GOTRUE_HOOK_SEND_SMS_SECRETS=v1,whsec_YOUR_SECRET_HERE
```

| Variable | Notes |
|----------|-------|
| `GOTRUE_HOOK_SEND_SMS_URI` | **Public HTTPS** API Gateway URL (not `localhost`) |
| `GOTRUE_HOOK_SEND_SMS_SECRETS` | Must match Lambda `SEND_SMS_HOOK_SECRETS` exactly |
| `GOTRUE_SMS_AUTOCONFIRM=false` | Users must verify OTP (hook sends it) |

Do **not** configure Twilio/MessageBird env vars if the hook handles all SMS — the hook replaces built-in providers.

### 3.2 Wire env vars in `docker-compose.yml`

Under `services:` → `auth:` → `environment:`, ensure these are passed from `.env`:

```yaml
      GOTRUE_EXTERNAL_PHONE_ENABLED: ${GOTRUE_EXTERNAL_PHONE_ENABLED}
      GOTRUE_SMS_AUTOCONFIRM: ${GOTRUE_SMS_AUTOCONFIRM}
      GOTRUE_HOOK_SEND_SMS_ENABLED: ${GOTRUE_HOOK_SEND_SMS_ENABLED}
      GOTRUE_HOOK_SEND_SMS_URI: ${GOTRUE_HOOK_SEND_SMS_URI}
      GOTRUE_HOOK_SEND_SMS_SECRETS: ${GOTRUE_HOOK_SEND_SMS_SECRETS}
```

If these lines are missing, GoTrue ignores the hook even when `.env` is set.

### 3.3 Restart auth

```bash
sudo docker compose up -d auth
sudo docker compose logs -f auth
```

---

## Step 4 — Verify networking (private EC2)

The `auth` container must reach API Gateway on the public internet.

From EC2:

```bash
curl -s -o /dev/null -w "%{http_code}\n" \
  -X POST "https://YOUR_API_ID.execute-api.eu-west-1.amazonaws.com/prod/auth/send-sms" \
  -H "Content-Type: application/json" \
  -d '{}'
```

- **401/403** → reachable (signature check failed — expected without valid headers)
- **000 / timeout** → NAT, security group egress, or DNS problem

Fix: ensure the Supabase EC2 subnet routes `0.0.0.0/0` via a **NAT gateway** (see `docs/private-subnet-lambda-sam.md`).

---

## Step 5 — Test end-to-end

### 5.1 Request OTP

```bash
curl -s -X POST 'https://supabase.datafy.co.za/auth/v1/otp' \
  -H 'apikey: YOUR_ANON_KEY' \
  -H 'Content-Type: application/json' \
  -d '{"phone":"+27821234567","create_user":true}'
```

Expected: **200** with empty or minimal body (OTP sent, not returned in response).

### 5.2 Check logs

```bash
# GoTrue
sudo docker compose logs --tail=50 auth | grep -iE 'hook|sms|error'

# Lambda
aws logs tail /aws/lambda/YOUR_FUNCTION_NAME --follow --region eu-west-1
```

### 5.3 Verify OTP

```bash
curl -s -X POST 'https://supabase.datafy.co.za/auth/v1/verify' \
  -H 'apikey: YOUR_ANON_KEY' \
  -H 'Content-Type: application/json' \
  -d '{"phone":"+27821234567","token":"123456","type":"sms"}'
```

Replace `123456` with the OTP received on the phone.

---

## Migrating from hosted supabase.com

If the hook was already configured on hosted:

1. Hosted dashboard → **Authentication → Hooks → Send SMS** — note URI and secret (or export from project settings).
2. Use the **same secret** on self-hosted so you do not rotate Lambda verification logic.
3. Update `GOTRUE_HOOK_SEND_SMS_URI` only if API Gateway URL changed.
4. Confirm hosted used **HTTP** hook (not `pg-functions://`) when Lambda was the target.

---

## Troubleshooting

| Symptom | Likely cause | Fix |
|---------|--------------|-----|
| OTP never arrives | Hook not enabled in compose | Add `GOTRUE_HOOK_SEND_SMS_*` to `auth` environment |
| `hook requires secrets` | Missing `GOTRUE_HOOK_SEND_SMS_SECRETS` | Set secret in `.env` |
| Auth log: HTTP hook timeout | EC2 can't reach API Gateway | NAT / egress / URL typo |
| Lambda 401 | Signature mismatch | Same secret both sides; check `v1,whsec_` prefix |
| Lambda 500 | Your `send_sms()` failed | CloudWatch logs; IAM for SNS/Pinpoint |
| User gets 500 on `/otp` | Lambda returned error JSON | Fix provider; return 200 `{}` on success |
| Phone sign-in disabled | `GOTRUE_EXTERNAL_PHONE_ENABLED` false | Set `true`, restart auth |
| Works on hosted, not self-hosted | Wrong URI or secret | Compare env with hosted hook config |

### Debug hook delivery from EC2

Install [Standard Webhooks](https://github.com/standard-webhooks/standard-webhooks) test tool or send a signed test request from your laptop to Lambda first, before testing through GoTrue.

---

## Security checklist

- [ ] Webhook secret stored in SSM / Secrets Manager — not committed to git
- [ ] Lambda verifies **every** request (no bypass in prod)
- [ ] API Gateway HTTPS only
- [ ] Lambda IAM least privilege (only SMS send permissions needed)
- [ ] Rate-limit `/auth/v1/otp` at WAF/ALB if abuse is a concern
- [ ] Rotate secret periodically — update `.env` and Lambda env together

---

## Quick reference — files to touch

| File | What to set |
|------|-------------|
| `supabase-project/.env` | `GOTRUE_HOOK_SEND_SMS_*`, phone auth flags |
| `supabase-project/docker-compose.yml` | Pass hook env vars into `auth` service |
| Lambda env / SSM | `SEND_SMS_HOOK_SECRETS` |
| SAM template | API route + function (if new) |

---

## Related

- `docs/supabase-auth-container-logs.md` — SSM login + `docker compose logs auth`
- `docs/supabase-aws-rds-proxy-setup.md` — SES for email; same `.env` / compose pattern for auth
- `docs/migrate-supabase-cloud-to-self-hosted.md` — cutover checklist
- [Auth hooks overview](https://supabase.com/docs/guides/auth/auth-hooks)
- [Send SMS hook schema](https://supabase.com/docs/guides/auth/auth-hooks/send-sms-hook)
