HashPass Auth (QR Login)
Passwordless login for hashpass.club: a browser shows a QR code, the
HashPass mobile app scans and approves it under the user's own session, and
the browser ends up with a real HashPass session — no password, no email
round trip.
Implementation detail (routes, env vars, local dev, Terraform, Phase 2
scope) lives in the packages/hashpass-links-api/README.md package guide.
This page covers the flow and where each piece lives, for orientation.
Why a separate service
apps/web-app (hashpass.club) is a static export with no server of its
own — unlike apps/mobile-app, which has API routes backed by
hashpass-{dev,prod}-expo-router-api on api.hashpass.tech. Rather than
turning apps/web-app into a standalone server, HashPass Auth is its own
small Lambda + API Gateway service, packages/hashpass-links-api, modeled
on the same aws_expo_router_api Terraform module the main API already
uses. apps/web-app stays 100% static and just calls this service over
HTTP.
Flow
- Browser starts a challenge.
apps/web-app'sSignInModalcalls@hashpass-tech/sdk'sAuthQrClient.beginLogin(), which generates a PKCE pair andPOSTscodeChallengeto/api/v1/auth/qr/challenges. The API returns an opaque challenge id, aqrUrl, astatevalue, and abindingsecret — derived from wherever the request actually reached the service, so it works whether that's the raw Lambda invoke URL (pre-hashpass.link-cutover) or the eventual custom domain. - Browser renders the QR and polls. The QR encodes
qrUrl. The browser pollsGET /api/v1/auth/qr/challenges/:id(sendingbindingback as anx-hashpass-bindingheader, plusstate) until the status changes frompending. - Mobile app scans and approves.
apps/mobile-app's existingQRScannercomponent recognizes the/auth/:idURL shape (lib/auth-qr.ts'sparseAuthQrScan) before falling through to its normal pass-token scanning pipeline, and routes to a dedicatedauth-qr-approvescreen. Under the user's own authenticated session, approving or denying callsAuthQrClient.respondToLogin(challengeId, decision). - Browser exchanges the code. Once approved, the poll response
includes a one-time authorization code. The browser calls
AuthQrClient.exchangeLogin()(or the higher-levelwaitForLogin(), which drives steps 2–4 in one call), which atomically consumes the challenge and returns a real Supabase session ({accessToken, refreshToken}).apps/web-apphands that straight tosupabase.auth.setSession().
Security properties
- Opaque, random, single-use, short-lived (180s) challenges and codes.
- Browser-session binding via an explicit
x-hashpass-bindingheader, not a cookie.hashpass.clubandhashpass.linkare different registrable domains, which makes a binding cookie a third-party cookie — and browsers that block third-party cookies (Safari by default, increasingly others) do so independently of theSameSiteattribute, so a cookie-based design would silently fail for a large share of real users. An explicit header sidesteps that entirely. - Explicit mobile-app approval — never auto-approved.
- PKCE (
code_challenge/code_verifier), timing-safe comparison. - Atomic single-use consumption at both the approve and exchange steps, so
two racing requests can't both succeed (see
packages/hashpass-links-api/src/router.test.ts's concurrent-exchange test).
Status
Phase 1 only — the login flow above, on real infra behind its default API
Gateway invoke URL. hashpass.link DNS/ACM has not been cut over yet. The
broader "HashPass Links" product (arbitrary dynamic QR links, management
dashboard, click analytics, public /q/:slug redirects) is Phase 2 and not
built — see the package README's "Phase 2 stub routes" section.