Passwordless Authentication with Magic Links — Eliminating Passwords from API Auth
In this tutorial, you will learn about Passwordless Authentication with Magic Links. We cover key concepts, practical examples, and best practices to help you master this topic.
Passwordless authentication uses one-time magic links sent via email or SMS to verify identity, eliminating passwords entirely and reducing phishing risk since there is no password to steal.
What You'll Learn
Magic link generation and verification, one-time token handling, link expiry and rotation, email delivery integration, and implementing passwordless authentication for APIs.
Why It Matters
Passwords are the weakest link in authentication. Users reuse passwords, choose weak ones, and fall for phishing. Passwordless authentication eliminates these problems by replacing passwords with time-limited cryptographic tokens.
Real-World Use
Slack uses magic links for initial login. Medium sends magic links for email-based login. Durga Antivirus Pro uses magic links for partner portal access, eliminating the need for partners to manage API passwords.
sequenceDiagram
participant User as User
participant App as Client App
participant API as Auth API
participant Email as Email Service
User->>App: Enter email address
App->>API: POST /auth/magic-link (email)
API->>API: Generate one-time token
+ store with expiry
API->>Email: Send magic link email
Email->>User: "Click to log in: https://api.durga.com/auth/verify?token=..."
User->>API: GET /auth/verify?token=...
API->>API: Validate token + mark used
API->>User: Redirect with session token
Code Example: Magic Link Generation and Sending
import secrets, hashlib, datetime, jwt
from flask import Flask, request, jsonify, redirect
from sendgrid import SendGridAPIClient
from sendgrid.helpers.mail import Mail
app = Flask(__name__)
SECRET = "magic-link-secret"
# Magic link store
magic_links = {}
def generate_magic_token():
"""Generate a cryptographically secure one-time token."""
return secrets.token_urlsafe(48)
def create_magic_link(email, redirect_to="/dashboard"):
"""Create a one-time magic link for the given email."""
token = generate_magic_token()
token_hash = hashlib.sha256(token.encode()).hexdigest()
expires_at = datetime.datetime.utcnow() + datetime.timedelta(minutes=15)
magic_links[token_hash] = {
"email": email,
"expires_at": expires_at,
"used": False,
"created_at": datetime.datetime.utcnow()
}
magic_link = f"https://api.durga-antivirus.com/auth/verify?token={token}&redirect={redirect_to}"
return magic_link, token_hash
@app.route("/api/auth/magic-link", methods=["POST"])
def request_magic_link():
"""Send a magic link to the user's email."""
email = request.json.get("email", "")
redirect_to = request.json.get("redirect", "/dashboard")
if not email or "@" not in email:
return jsonify({"error": "Valid email required"}), 400
# Always return success to prevent email enumeration
magic_link, token_hash = create_magic_link(email, redirect_to)
try:
send_magic_link_email(email, magic_link)
except Exception as e:
print(f"Failed to send email: {e}")
return jsonify({
"message": "If the email is registered, a magic link has been sent.",
"expires_in": 900
})
def send_magic_link_email(to_email, magic_link):
"""Send magic link via SendGrid."""
message = Mail(
from_email="noreply@durga-antivirus.com",
to_entries=[to_email],
subject="Your login link for Durga Antivirus",
html_content=f"""
<p>Click the link below to log in to your Durga Antivirus account:</p>
<p><a href="{magic_link}">{magic_link}</a></p>
<p>This link expires in 15 minutes.</p>
<p>If you did not request this, please ignore this email.</p>
"""
)
sg = SendGridAPIClient(os.environ.get("SENDGRID_API_KEY"))
sg.send(message)
Code Example: Magic Link Verification
@app.route("/auth/verify", methods=["GET"])
def verify_magic_link():
"""Verify a magic link token and issue session."""
token = request.args.get("token", "")
redirect_to = request.args.get("redirect", "/dashboard")
if not token:
return jsonify({"error": "Missing token"}), 400
token_hash = hashlib.sha256(token.encode()).hexdigest()
stored = magic_links.get(token_hash)
if not stored:
return jsonify({"error": "Invalid or expired magic link"}), 401
if stored["used"]:
return jsonify({"error": "Magic link already used"}), 401
if datetime.datetime.utcnow() > stored["expires_at"]:
del magic_links[token_hash]
return jsonify({"error": "Magic link expired"}), 401
# Mark as used
stored["used"] = True
# Issue JWT session token
access_token = jwt.encode({
"sub": stored["email"],
"auth_method": "magic_link",
"iat": datetime.datetime.utcnow(),
"exp": datetime.datetime.utcnow() + datetime.timedelta(hours=24),
"jti": secrets.token_hex(16)
}, SECRET, algorithm="HS256")
# Redirect with token
return redirect(f"{redirect_to}?access_token={access_token}&expires_in=86400")
@app.route("/api/auth/magic-link/status", methods=["GET"])
def check_link_status():
"""Check if a magic link has been used (for polling clients)."""
token = request.args.get("token", "")
token_hash = hashlib.sha256(token.encode()).hexdigest()
stored = magic_links.get(token_hash)
if not stored or stored["used"]:
return jsonify({"status": "consumed"})
if datetime.datetime.utcnow() > stored["expires_at"]:
return jsonify({"status": "expired"})
return jsonify({"status": "pending", "expires_in": int((stored["expires_at"] - datetime.datetime.utcnow()).total_seconds())})
Code Example: Client-Side Magic Link Flow
class PasswordlessAuth {
constructor(apiBase) {
this.apiBase = apiBase;
this.pollInterval = null;
}
async requestLogin(email) {
const resp = await fetch(`${this.apiBase}/api/auth/magic-link`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, redirect: window.location.origin + '/callback' })
});
return resp.json();
}
async checkTokenFromUrl() {
const params = new URLSearchParams(window.location.search);
const token = params.get('access_token');
if (token) {
// Clean URL
window.history.replaceState({}, '', window.location.pathname);
return { access_token: token, expires_in: parseInt(params.get('expires_in')) };
}
return null;
}
// For apps that open email link in the same browser
async pollForToken(requestId) {
return new Promise((resolve, reject) => {
this.pollInterval = setInterval(async () => {
const token = await this.checkTokenFromUrl();
if (token) {
clearInterval(this.pollInterval);
resolve(token);
}
}, 1000);
// Timeout after 5 minutes
setTimeout(() => {
clearInterval(this.pollInterval);
reject(new Error('Login timeout'));
}, 300000);
});
}
}
// Usage
const auth = new PasswordlessAuth('https://api.durga-antivirus.com');
const result = await auth.requestLogin('user@example.com');
// Wait for user to click link in email
Common Mistakes
1. Revealing Whether an Email Is Registered
Always return the same response whether the email exists or not. Otherwise, attackers can enumerate valid email addresses.
2. Reusable Magic Links
Magic links must be single-use. After verification, the token is invalid. An attacker who intercepts the link before the user clicks should find it already used.
3. Long Token Expiry
15 minutes is the recommended maximum. Longer Windows increase the risk of link interception from email compromise.
4. No Rate Limiting on Requests
Attackers can flood email inboxes with magic link requests. Rate limit to 1-3 requests per email per 5 minutes.
5. Magic Links in URLs Without HTTPS
Magic links contain one-time tokens. If sent over HTTP, the token can be intercepted. Always use HTTPS for the entire flow.
Practice Questions
- How does a magic link authenticate the user?
- Why should the server return the same response for known and unknown emails?
- How long should a magic link be valid?
- What prevents an attacker from using a magic link they intercepted?
- How does the polling client detect successful authentication?
Answers:
- The server sends a cryptographically random token to the user's email. Clicking the link proves the user controls that email address. The server issues a session token.
- To prevent email enumeration. If different responses are returned, an attacker can determine which emails are registered on the platform.
- 15 minutes maximum. Long enough for the user to check their email, short enough to limit the window for interception.
- Magic links are single-use. After the legitimate user clicks, the token is invalidated. If the attacker clicks first, the legitimate user's link fails.
- The server redirects the browser to the callback URL with an access_token parameter. The frontend reads this parameter and stores the token.
Challenge: Build a complete passwordless authentication system with magic link generation, email sending (with mock), one-time verification, token expiry, and a client that polls for successful authentication.
FAQ
Mini Project
Build a passwordless authentication system with Flask: magic link generation, SendGrid email integration (with mock fallback), one-time verification, token issuance, and a simple frontend that requests magic links and handles the callback.
What's Next
Now explore Social Login Providers for authenticating users through Google, GitHub, and Apple.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro