Prompt
How do I implement passwordless login with magic links?
Latest observation
Passwordless login with magic links usually works like this:
- User enters their email address.
- Your backend generates a single-use, short-lived token.
- You email a link containing that token.
- When the user clicks the link, your backend verifies the token and creates a session.
Basic flow
1) Request link
User submits:
POST /auth/magic-link
{
"email": "user@example.com"
}
Backend:
- Validate email format
- Rate-limit requests
- Generate secure token
- Store only a hashed version of the token with:
- email/user ID
- expiration time
- usage status
- Email a link like:
https://yourapp.com/auth/callback?token=...&email=user@example.com
2) Verify link
When clicked:
GET /auth/callback?token=...&email=user@example.com
Backend:
- Hash the received token
- Look it up
- Check it exists, isn’t expired, and hasn’t been used
- Mark it used
- Create a session / set auth cookie / return JWT
Important security practices
Use cryptographically secure tokens
Example:
- 32+ random bytes
- Encode with base64url or hex
Make tokens short-lived
Typical lifetime:
- 10–15 minutes
Make tokens single-use
After successful login:
- invalidate/delete token
Store hashed tokens
Don’t store raw magic tokens in the DB. Store something like:
sha256(token)
Rate limit requests
Prevent abuse by limiting:
- per email
- per IP
- per device/browser
Prevent account enumeration
Always respond with something like:
“If that email exists, we sent a link.”
Bind to optional context
You can optionally bind the magic link to:
- user agent
- IP range
- intended app/client
Be careful: too much binding can hurt usability.
Use HTTPS only
Never send magic links over insecure HTTP.
Consider CSRF/session security
If you set cookies after login:
- use
HttpOnly SecureSameSite=LaxorStrictdepending on your flow
Example schema
magic_login_tokens
| column | type | notes |
|---|---|---|
| id | UUID | primary key |
| user_id | UUID | nullable if email not yet mapped |
| text | ||
| token_hash | text | sha256 of token |
| expires_at | timestamp | |
| used_at | timestamp | nullable |
| created_at | timestamp |
Example implementation outline
Generate token
import secrets, hashlib
from datetime import datetime, timedelta, timezone
token = secrets.token_urlsafe(32)
token_hash = hashlib.sha256(token.encode()).hexdigest()
expires_at = datetime.now(timezone.utc) + timedelta(minutes=15)
Store token_hash in DB, then email the raw token.
Verify token
received_hash = hashlib.sha256(received_token.encode()).hexdigest()
# lookup by received_hash
# check expires_at > now and used_at is null
# mark used_at = now
# create session
Email link UX tips
- Show a confirmation page after request:
- “Check your email for your sign-in link.”
- Include a fallback code if email clients break links
- Consider deep links for mobile apps
- Provide a “resend link” button with throttling
Common pitfalls
- Storing raw tokens in the database
- Long-lived tokens
- Reusing tokens
- Not rate limiting
- Exposing whether an email exists
- Logging full tokens in server logs or analytics
If you’re using JWTs
You can still use magic links, but the magic-link token itself should be opaque and random, not a JWT. The token’s job is just to prove email ownership once. After that, issue your normal session/JWT.
If you want, I can also provide:
- a Node.js/Express example
- a Django example
- a Next.js example
- or a database schema + email template