A production-quality passwordless authentication system using magic links + OTP, built with Node.js, TypeScript, PostgreSQL, and Redis.
- User enters any email (Gmail, Yahoo, Outlook, custom — all accepted)
- Server generates a random 8-digit OTP + magic link token, stores both (hashed) in Redis with a 250 s TTL
- Brevo sends a styled email containing both a magic link button and the OTP
- A top progress bar counts down 250 seconds; a ring countdown is shown inside the OTP step
- User clicks the link OR types the OTP — whichever comes first
- Token/OTP is consumed (deleted) immediately on first use
- A JWT session cookie is issued and the user lands on the dashboard
| Tool | Install |
|---|---|
| Node.js >= 18 | https://nodejs.org |
| Docker Desktop | https://www.docker.com/products/docker-desktop/ |
| Git Bash (Windows) | included with Git for Windows |
cd passwordless-auth
npm install- Go to https://www.brevo.com and create a FREE account
- Verify your email address
- After logging in: click the gear icon (top-right) → SMTP & API → SMTP tab
- You will see your Login email — use this as EMAIL_USER
- Click "Generate a new SMTP key" → copy it as EMAIL_PASS
- EMAIL_FROM must be the same email you signed up with
No Brevo yet? Without credentials the app automatically falls back to Ethereal (fake inbox). Emails are never delivered but the server logs print a preview URL like
DEV email preview { url: 'https://ethereal.email/message/...' }. Perfect for local dev.
Open the .env file. Fill in your Brevo details:
[email protected]
EMAIL_PASS=xsmtpsib-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
[email protected]
Everything else is already correct for local development.
Open Docker Desktop first, then:
docker-compose -f infra/docker-compose.yml up -d db redisWait ~5 seconds. Verify both are healthy:
docker ps
# auth_db and auth_redis should show "(healthy)"npx ts-node scripts/migrate.ts
# Should print: Connected to database -> Migration completed successfullyIf you see "database does not exist" — the DB container hasn't finished initialising yet. Wait 10 s and retry.
npm run devOpen http://localhost:3000 in your browser.
npm test
# All 17 tests should passRedis stores OTP tokens, magic link tokens, session allow-list entries, and rate-limit counters. All have short TTLs.
The Docker container runs with no password on port 6379. The .env already has:
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD= # empty = no password required
If Redis is not running the server logs "Redis error" and retries. The app still starts but login will fail.
Stores users and audit logs. The Docker container auto-creates the database and runs scripts/migrate.sql on first boot.
| Error | Fix |
|---|---|
| database "passwordless-auth-db" does not exist | Wrong DB_NAME in .env — must be passwordless_auth_db (underscores, not hyphens) |
| Failed to start server (Redis error) | Run docker-compose -f infra/docker-compose.yml up -d redis |
| "Security token missing" in browser | CSRF bug — fixed in this version |
| Send login link button does nothing | CSRF bug — fixed in this version |
| Email not arriving | Add Brevo SMTP key to .env. Without it, copy preview URL from server logs. |
| Migration failed immediately | DB container not healthy yet — wait 10 s then retry |
| Variable | Description | Default |
|---|---|---|
| NODE_ENV | development or production | development |
| PORT | HTTP port | 3000 |
| BASE_URL | Public URL (for magic links) | http://localhost:3000 |
| DB_HOST | Postgres host | localhost |
| DB_PORT | Postgres port | 5432 |
| DB_USER | Postgres user | auth_user |
| DB_PASSWORD | Postgres password | auth_password |
| DB_NAME | Postgres database name | passwordless_auth_db |
| REDIS_HOST | Redis host | localhost |
| REDIS_PORT | Redis port | 6379 |
| REDIS_PASSWORD | Redis password (empty = none) | (empty) |
| JWT_SECRET | Secret for JWT signing — change in prod | (dev value) |
| CSRF_SECRET | Secret for CSRF token signing — change in prod | (dev value) |
| EMAIL_HOST | SMTP host | smtp-relay.brevo.com |
| EMAIL_PORT | SMTP port | 587 |
| EMAIL_SECURE | TLS on connect (true/false) | false |
| EMAIL_USER | Your Brevo login email | (must set) |
| EMAIL_PASS | SMTP key from Brevo | (must set) |
| EMAIL_FROM | Sender address shown in emails | (must set) |
| OTP_EXPIRY_SECONDS | How long OTP is valid | 250 |
| MAGIC_LINK_EXPIRY_SECONDS | How long magic link is valid | 250 |
| MAX_LOGIN_ATTEMPTS | Max failed attempts before lockout | 5 |
| LOCKOUT_DURATION_MINUTES | How long account is locked | 15 |