Skip to content

Fix SMS + CoinPay sign-in, add passkey sign-up and sign-in - #273

Merged
ralyodio merged 2 commits into
masterfrom
fix/auth-sms-coinpay-passkeys
Oct 6, 2026
Merged

ralyodio merged 2 commits into
masterfrom
fix/auth-sms-coinpay-passkeys

Conversation

@ralyodio

@ralyodio ralyodio commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

This fixes two broken sign-in paths on qrypt.chat and adds passkeys.

SMS login: "Unable to get SMS provider"

Why it failed:

  • dev2's Supabase Auth had phone sign-in switched on but no SMS provider configured.
  • The Twilio account it was set up with now answers 401 to every credential we hold: qrypt's, profullstack.com's, and the API-key pair.

The fix: GoTrue now delivers codes through its Send SMS hook.

  • POST /api/auth/hooks/send-sms verifies the Standard Webhooks signature (webhook-id, -timestamp, -signature, with a 5-minute window).
  • It then sends the code through Telnyx from +14084269127, a number with recent delivered outbound texts.
  • New env vars: TELNYX_API_KEY, TELNYX_SMS_FROM, SEND_SMS_HOOK_SECRET. They're in app.env on dev2 and in the qryptchat-web--prod vault.
  • /api/auth/hooks/* uses the webhook rate limit instead of the per-IP auth limit. Every hook call comes from GoTrue's single address, so the auth limit would have capped the whole site at 10 codes a minute.

GoTrue's side is enabled after this deploys (GOTRUE_HOOK_SEND_SMS_* in volumes/auth/auth.env, cli-tools #136).

CoinPay login "just refreshes"

Why it failed:

  • CoinPay reports email_verified: false for every account, since its 2026-08-19 security fix.
  • Our callback refused any unverified email, so every CoinPay login was refused.
  • /auth never displayed ?error=. In popup mode the popup reloaded /auth, and that looked like a refresh.
  • Redirect mode had a second bug: on success it set cookies the app never reads, so /chat bounced you straight back to /auth.

The fix:

  • Identity is now CoinPay's sub, never the unverified email. Linking by email would let anyone claim an account by typing its address into CoinPay.
  • Existing CoinPay accounts already carry coinpay_sub in their metadata and keep working.
  • New ones are addressed <sub>@coinpay.qrypt.chat, and the email CoinPay sent is kept as metadata only.
  • Errors now reach you: popup mode posts them to the opener, and redirect mode uses ?error=. /auth shows a readable message for each.
  • Redirect-mode success hands the session to /auth in the URL fragment. A fragment never reaches a server log.

Passkeys (@simplewebauthn/server + /browser v14)

  • Sign up with just a username and a passkey, so no phone number is needed.
  • Sign in with one tap. The flow is discoverable, so the endpoint never says whether an account exists.
  • Settings → Passkeys lists them and adds or removes one, so any SMS or CoinPay account can add a passkey.
  • Relying party is the host the browser is on: qrypt.chat, or the onion address over Tor. A passkey works on the host that created it.
  • Challenges are single-use and last 5 minutes. The account a passkey belongs to always comes from the stored challenge, never from the request body.
  • Sessions come from the same magic-link bridge qc uses (mintSession).
  • New accounts generate E2EE keys and go to the backup-PIN step, like a new phone account.
  • Migration 20261006150000_passkeys: webauthn_credentials and webauthn_challenges, RLS on, service role only. It is already applied on dev2.

/auth

CoinPay and passkeys now share one completeLogin() for session storage and the key lifecycle, instead of separate copies.

Tests

19 new tests:

  • SMS hook: signature (good, rotated, tampered, wrong key, stale, missing) and the route end to end with a mocked Telnyx
  • Passkeys: the relying-party allow-list; sign-up validation, taken usernames, verify-then-provision, one-time challenges, and no account when verification fails; adding to an existing account and exclusion of existing passkeys; sign-in with the counter update, an unknown credential, and a failed signature
  • CoinPay: identity by sub, ignoring a matching email

Results:

  • bun run test:ci: 612/612.
  • next build passes.

🤖 Generated with Claude Code

SMS: Supabase Auth on dev2 had no SMS provider, and the Twilio account it
used now rejects every credential (401). Codes now go through GoTrue's
Send SMS hook: POST /api/auth/hooks/send-sms verifies the Standard
Webhooks signature and sends via Telnyx (TELNYX_API_KEY, TELNYX_SMS_FROM,
SEND_SMS_HOOK_SECRET). The hook is exempt from the per-IP auth limit,
which would otherwise cap the site at 10 codes a minute.

CoinPay: every login was refused because CoinPay reports
email_verified=false for all accounts, and /auth never showed ?error=, so
it looked like a refresh. Identity is now CoinPay's sub (never the
unverified email, which would let anyone claim an address they typed);
existing CoinPay accounts carry coinpay_sub and keep working. Errors are
shown: popup posts them to the opener, redirect mode via ?error=. The
redirect-mode success no longer sets cookies the app never reads; it
hands the session to /auth in the URL fragment.

Passkeys (@simplewebauthn): create an account with only a username and a
passkey, sign in with one tap (discoverable, so no account enumeration),
and add/remove passkeys in Settings. RP is the host the browser is on
(qrypt.chat or the onion). Sessions come from the shared magic-link
bridge (mintSession). Migration 20261006150000_passkeys: RLS on, service
role only.

/auth: one completeLogin() for CoinPay and passkeys (session storage + the
E2EE key lifecycle).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 6, 2026

Copy link
Copy Markdown

ThreatCrush Security Scan

13 finding(s)

MEDIUM: 9 | LOW: 4

Severity Rule Location
MEDIUM redos-nested-quantifier src/app/api/profile/update/route.js:73
MEDIUM js-unescaped-html-sink src/app/blog/[slug]/page.jsx:38
MEDIUM js-unescaped-html-sink src/app/blog/[slug]/page.jsx:66
MEDIUM js-unescaped-html-sink src/app/faq/page.jsx:57
MEDIUM js-unescaped-html-sink src/app/layout.jsx:137
MEDIUM js-unescaped-html-sink src/app/layout.jsx:141
MEDIUM js-unescaped-html-sink src/app/page.jsx:47
MEDIUM redos-nested-quantifier src/lib/auth/dns-name.js:88
MEDIUM js-unescaped-html-sink src/lib/components/chat/MessageItem.jsx:63
LOW secret-generic-credential src/app/api/auth/register-anon/route.test.js:32
LOW secret-jwt tests/debug-sms.js:10
LOW secret-generic-credential tests/private-key-import-export.test.js:252
LOW secret-generic-credential tests/private-key-import-export.test.js:264

Snippets are redacted; ThreatCrush never prints matched credential material.

@socket-security

socket-security Bot commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Added@​simplewebauthn/​browser@​14.0.01001009286100
Added@​simplewebauthn/​server@​14.0.3981008892100

View full report

Comment thread src/app/api/auth/coinpay/callback/route.js Fixed
Comment thread src/app/api/auth/coinpay/callback/route.js Fixed
Comment thread src/app/auth/page.jsx Fixed
Comment thread src/app/api/auth/coinpay/callback/route.js Fixed
A session in the URL fragment let a crafted link sign a victim into the
attacker's account (CodeQL js/user-controlled-bypass). The callback now
sets a 2-minute cookie on /auth that the page reads once and deletes;
another site cannot set it. Also stop logging the provider's error text
and only consult ?error= when there is no code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ralyodio
ralyodio merged commit 59da84f into master Oct 6, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants