Complete guide to passwordless authentication
Get up and running with Magic Link authentication in 4 simple steps!
cfg_abc123xyz)Send a POST request to create and email a magic link. This endpoint can be called from your browser frontend or backend server:
curl -X POST "https://www.lynxidentity.com/auth/magic-link/request" \
-H "Content-Type: application/json" \
-d '{"email":"user@example.com","configuration_id":"cfg_YOUR_CONFIGURATION_ID"}'/magic-link/server-request instead.Expected response:
HTTP/1.1 200 OK
Content-Type: application/json
{
"success": true,
"message": "Magic link sent successfully",
"expires_in_seconds": 900
}When the user clicks the magic link, verify the token server-to-server:
curl -X GET "https://www.lynxidentity.com/auth/magic-link/verify?token=THE_TOKEN_FROM_THE_EMAIL" \
-H "Authorization: Bearer mlt_YOUR_CLIENT_TOKEN"Expected response:
HTTP/1.1 200 OK
Content-Type: application/json
{
"success": true,
"message": "User verified. Frontend SHOULD POST the returned access_token to the BFF endpoint /auth/start to create a server session (HttpOnly cookie).",
"token_flow": {
"note": "Do not expose access_token to client-side storage.",
"post_to_bff_example": "fetch('/auth/start', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ access_token: '<ACCESS_TOKEN>' }), credentials: 'include' })"
},
"user_id": "user_550e8400",
"email": "user@example.com",
"redirect_url": "https://yourapp.com/dashboard"
}Real-world implementation patterns for different scenarios.
Magic links reduce the "activation tax" that normally kills conversion. Instead of forcing a new user to create a password, confirm it, remember it, and then log in again later, you collapse all of that into a single tap. That makes them ideal for low‑friction onboarding and lightweight identity creation.
Here's why they're so effective:
Magic links are especially strong when you want to create just enough identity to personalize, track, or transact — without forcing a full account setup. Think:
In all of these, the magic link acts as a "soft identity" that can later be deepened into a full profile if needed.
The only caveat is that magic links rely on email access, so they're strongest when:
If those conditions hold, magic links are a growth engine.
// Backend: Customer acquisition endpoint
app.post('/api/auth/acquire-customer', async (req, res) => {
const { email, source = 'website' } = req.body;
// Check if user already exists
const existingUser = await db.users.findByEmail(email);
if (existingUser) {
// For acquisition, we can still send the link to log them in
// Or redirect to login flow - here we'll proceed with magic link
}
// Send acquisition magic link (no auth required - can be called from browser or server)
await fetch(process.env.MAGIC_LINK_API_URL + '/auth/magic-link/request', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
email: email,
configuration_id: process.env.MAGIC_LINK_CONFIG_ID,
metadata: {
flow: 'customer_acquisition',
source: source,
timestamp: Date.now()
}
})
});
res.json({
success: true,
message: 'Welcome! Check your email to get started.'
});
});
// Verification handler for acquisition
app.get('/api/auth/verify-acquisition', async (req, res) => {
const { token } = req.query;
const verifyResponse = await fetch(
`${process.env.MAGIC_LINK_API_URL}/auth/magic-link/verify?token=${token}`,
{
headers: { 'Authorization': `Bearer ${process.env.MAGIC_LINK_CLIENT_TOKEN}` }
}
);
const data = await verifyResponse.json();
if (data.success) {
// Create or update user account
let user = await db.users.findByEmail(data.email);
if (!user) {
user = await db.users.create({
email: data.email,
status: 'active',
acquiredAt: new Date(),
acquisitionSource: data.metadata?.source || 'unknown'
});
}
// Create session
req.session.userId = user.id;
req.session.acquisitionFlow = true;
// Track acquisition metrics
await db.analytics.track('customer_acquired', {
userId: user.id,
source: data.metadata?.source,
timestamp: data.metadata?.timestamp
});
res.json({
success: true,
redirect: '/onboarding?new=true',
user: { id: user.id, email: user.email }
});
} else {
res.status(400).json(data);
}
});function CustomerAcquisitionForm() {
const [step, setStep] = useState('acquire'); // 'acquire' or 'sent'
const [email, setEmail] = useState('');
const [source, setSource] = useState('website');
const handleSubmit = async (e) => {
e.preventDefault();
await fetch('/api/auth/acquire-customer', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, source })
});
setStep('sent');
};
return (
<div className="acquisition-form">
{step === 'acquire' ? (
<form onSubmit={handleSubmit}>
<h2>Get Started Instantly</h2>
<p>No passwords, no hassle - just your email.</p>
<input
type="email"
placeholder="Enter your email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<select value={source} onChange={(e) => setSource(e.target.value)}>
<option value="website">Website</option>
<option value="ad">Advertisement</option>
<option value="referral">Referral</option>
<option value="social">Social Media</option>
</select>
<button>Get Magic Link</button>
</form>
) : (
<div className="success-message">
<h2>Check Your Email!</h2>
<p>We've sent a magic link to {email}</p>
<p>Click it to instantly access your account.</p>
</div>
)}
</div>
);
}Replace traditional password reset emails with secure magic links.
// Backend: Password reset endpoint
app.post('/api/auth/reset-password', async (req, res) => {
const { email } = req.body;
// Check if user exists (optional - for privacy, always return success)
const user = await db.users.findByEmail(email);
if (user) {
// Request magic link with custom redirect (no auth required)
await fetch(process.env.MAGIC_LINK_API_URL + '/auth/magic-link/request', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
email: email,
configuration_id: process.env.MAGIC_LINK_CONFIG_ID,
// Optional: customize redirect for password reset flow
metadata: { flow: 'password_reset' }
})
});
}
// Always return success to prevent email enumeration
res.json({
success: true,
message: 'If an account exists, a reset link has been sent'
});
});
// Verification handler with password reset context
app.get('/api/auth/verify-reset', async (req, res) => {
const { token } = req.query;
const verifyResponse = await fetch(
`${process.env.MAGIC_LINK_API_URL}/auth/magic-link/verify?token=${token}`,
{
headers: { 'Authorization': `Bearer ${process.env.MAGIC_LINK_CLIENT_TOKEN}` }
}
);
const data = await verifyResponse.json();
if (data.success) {
// Create temporary session for password reset only
const resetToken = generateSecureToken();
await db.passwordResetTokens.create({
userId: data.user_id,
token: resetToken,
expiresAt: Date.now() + 15 * 60 * 1000 // 15 minutes
});
res.json({
success: true,
resetToken,
redirect: '/reset-password?token=' + resetToken
});
} else {
res.status(400).json(data);
}
});function PasswordResetForm() {
const [step, setStep] = useState('request'); // 'request' or 'sent'
const [email, setEmail] = useState('');
const handleSubmit = async (e) => {
e.preventDefault();
await fetch('/api/auth/reset-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email })
});
setStep('sent');
};
return (
<div>
{step === 'request' ? (
<form onSubmit={handleSubmit}>
<h2>Reset Your Password</h2>
<input
type="email"
placeholder="Enter your email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<button>Send Reset Link</button>
</form>
) : (
<div>
<h2>Check Your Email</h2>
<p>We've sent a password reset link to {email}</p>
<p>Click the link to reset your password.</p>
</div>
)}
</div>
);
}Verify email addresses during registration without separate password creation.
// Backend: Registration with email verification
app.post('/api/auth/register', async (req, res) => {
const { email, name } = req.body;
// Create unverified user
const user = await db.users.create({
email,
name,
emailVerified: false,
status: 'pending_verification'
});
// Send verification magic link
await fetch(process.env.MAGIC_LINK_API_URL + '/auth/magic-link/request', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.MAGIC_LINK_CLIENT_TOKEN}`
},
body: JSON.stringify({
email: email,
configuration_id: process.env.MAGIC_LINK_CONFIG_ID,
metadata: {
flow: 'email_verification',
userId: user.id
}
})
});
res.json({
success: true,
message: 'Registration successful! Check your email to verify.'
});
});
// Verification handler
app.get('/api/auth/verify-email', async (req, res) => {
const { token } = req.query;
const verifyResponse = await fetch(
`${process.env.MAGIC_LINK_API_URL}/auth/magic-link/verify?token=${token}`,
{
headers: { 'Authorization': `Bearer ${process.env.MAGIC_LINK_CLIENT_TOKEN}` }
}
);
const data = await verifyResponse.json();
if (data.success) {
// Mark email as verified
await db.users.update(data.user_id, {
emailVerified: true,
status: 'active'
});
// Create session
req.session.userId = data.user_id;
res.json({
success: true,
redirect: '/dashboard'
});
} else {
res.status(400).json(data);
}
});Handle organization-specific login flows with different configurations.
// Backend: Tenant-specific login
app.post('/api/auth/login/:tenantId', async (req, res) => {
const { tenantId } = req.params;
const { email } = req.body;
// Get tenant-specific configuration
const tenant = await db.tenants.findById(tenantId);
if (!tenant || !tenant.active) {
return res.status(404).json({ error: 'Tenant not found' });
}
// Verify user belongs to this tenant
const user = await db.users.findOne({
email,
tenantId
});
if (!user) {
// Still return success for privacy
return res.json({
success: true,
message: 'If an account exists, a login link has been sent'
});
}
// Send magic link with tenant-specific config (no auth required)
await fetch(process.env.MAGIC_LINK_API_URL + '/auth/magic-link/request', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
email: email,
configuration_id: tenant.magicLinkConfigId, // Tenant-specific
metadata: {
tenantId: tenant.id,
tenantName: tenant.name
}
})
});
res.json({
success: true,
message: 'Login link sent to ' + email
});
});
// Tenant-specific verification
app.get('/api/auth/verify/:tenantId', async (req, res) => {
const { tenantId } = req.params;
const { token } = req.query;
const verifyResponse = await fetch(
`${process.env.MAGIC_LINK_API_URL}/auth/magic-link/verify?token=${token}`,
{
headers: { 'Authorization': `Bearer ${process.env.MAGIC_LINK_CLIENT_TOKEN}` }
}
);
const data = await verifyResponse.json();
if (data.success) {
// Verify user belongs to tenant
const user = await db.users.findOne({
id: data.user_id,
tenantId: tenantId
});
if (!user) {
return res.status(403).json({ error: 'User not authorized for this tenant' });
}
// Create tenant-scoped session
req.session.userId = data.user_id;
req.session.tenantId = tenantId;
res.json({
success: true,
redirect: `/${tenantId}/dashboard`
});
} else {
res.status(400).json(data);
}
});Provide time-limited access to specific resources without full account creation.
// Backend: Share a protected resource
app.post('/api/share/document/:docId', async (req, res) => {
const { docId } = req.params;
const { recipientEmail, expiresInHours = 24 } = req.body;
// Verify sender has access to document
const document = await db.documents.findById(docId);
if (!document || document.ownerId !== req.session.userId) {
return res.status(403).json({ error: 'Access denied' });
}
// Create temporary access token
const accessToken = generateSecureToken();
await db.sharedAccess.create({
documentId: docId,
email: recipientEmail,
token: accessToken,
expiresAt: Date.now() + expiresInHours * 60 * 60 * 1000
});
// Send magic link for guest access (no auth required)
await fetch(process.env.MAGIC_LINK_API_URL + '/auth/magic-link/request', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
email: recipientEmail,
configuration_id: process.env.MAGIC_LINK_GUEST_CONFIG_ID,
metadata: {
flow: 'guest_access',
documentId: docId,
accessToken
}
})
});
res.json({
success: true,
message: 'Access link sent to ' + recipientEmail
});
});
// Guest access verification
app.get('/api/share/verify', async (req, res) => {
const { token } = req.query;
const verifyResponse = await fetch(
`${process.env.MAGIC_LINK_API_URL}/auth/magic-link/verify?token=${token}`,
{
headers: { 'Authorization': `Bearer ${process.env.MAGIC_LINK_CLIENT_TOKEN}` }
}
);
const data = await verifyResponse.json();
if (data.success && data.metadata?.flow === 'guest_access') {
// Verify access hasn't expired
const access = await db.sharedAccess.findOne({
token: data.metadata.accessToken,
email: data.email
});
if (!access || access.expiresAt < Date.now()) {
return res.status(410).json({ error: 'Access link has expired' });
}
// Create temporary guest session
req.session.guestId = generateId();
req.session.accessToken = access.token;
req.session.documentId = access.documentId;
res.json({
success: true,
redirect: `/documents/${access.documentId}/view`
});
} else {
res.status(400).json({ error: 'Invalid access link' });
}
});Welcome to Magic Link! This guide will help you integrate passwordless authentication into your application in just a few minutes.
Magic Link provides secure, passwordless authentication via email. Users receive a unique, time-limited link that logs them in automatically-no passwords to remember or manage.
As digital experiences evolve, so do the expectations users bring with them. People want speed, convenience, and security-without the friction of forgotten passwords, reset emails, or complex login flows. Magic links have emerged as one of the most promising solutions to this challenge.
At their core, magic links are a form of passwordless authentication. Instead of relying on a user-chosen secret (a password), the system generates a short-lived, single-use token and delivers it to the user through a trusted channel-typically email. When the user clicks the link, the system verifies the token and grants access.
This approach eliminates the cognitive load of password management and reduces the attack surface associated with weak or reused passwords. It also aligns with how people already behave: most users treat their email inbox as a secure identity hub, and magic links leverage that familiarity.
Several trends have converged to make magic links increasingly attractive:
Magic links meet these expectations while reducing operational overhead for product teams. They also integrate well with modern architectures, from traditional server-rendered apps to SPAs and mobile clients.
A common critique of magic links is that if a user's email account is compromised, their magic-link access is compromised as well. This was a more significant concern a decade ago, when email accounts were often protected only by a password.
Today's Reality: Email security has evolved dramatically. Most users access their inbox primarily through mobile devices that enforce biometric locks, hardware-backed encryption, and secure enclave key storage. Major email providers now default to multi-factor authentication, suspicious-login detection, device-based verification, and continuous risk scoring.
In practice, a modern email inbox is often more secure than the average password a user would create for your application. Magic links effectively inherit the strength of the user's device and email provider-both of which have become far more resilient than traditional password-based authentication.
Magic links are not a one-size-fits-all solution, but they excel in several scenarios:
Apps where users log in occasionally-marketplaces, newsletters, booking platforms, community sites-benefit enormously. Users don't need to remember a password for something they access once a week or once a month.
Magic links shine when you want to reduce barriers for new users. Removing passwords from sign-up can significantly increase activation rates.
Magic links simplify authentication for organizations that don't want to manage passwords for their users. They also integrate cleanly with guest-to-account conversion flows.
Typing passwords on mobile devices is painful. Magic links offer a tap-friendly alternative that feels natural on phones and tablets.
If your product already relies heavily on email-notifications, receipts, confirmations-magic links fit neatly into that ecosystem.
Despite their strengths, magic links are not ideal for every use case:
Although the user experience is simple, the infrastructure behind magic links is complex. A robust implementation must address several intertwined concerns:
Tokens must be random, hashed, short-lived, single-use, and consumed atomically. They must be tied to a specific redirect URL and client configuration. Any weakness here opens the door to replay attacks, token guessing, or unauthorized access.
Delivering authentication emails reliably requires SPF, DKIM, DMARC alignment, bounce handling, spam monitoring, and IP reputation management. Without this, login emails land in spam-or never arrive at all.
Magic links redirect users back to the client application. Without strict allowlists and validation, attackers can exploit open redirects to steal tokens or phish users.
The most secure flows require the client backend to redeem tokens directly with the provider. This demands strong client authentication, consistent error handling, and audit logging.
Magic link endpoints are prime targets for email bombing, enumeration attacks, and automated abuse. Rate limiting, anomaly detection, and monitoring are essential.
A production system must provide telemetry, alerting, audit trails, key rotation, and multi-tenant configuration. These are not optional-they are the backbone of a reliable authentication service.
✓ Built for You: Our Magic Link SaaS provides a production-ready implementation with all of these features built in, so you can focus on your product rather than reinventing authentication infrastructure.
For some organizations, building and maintaining authentication infrastructure in-house makes sense. They may have specialized security teams, dedicated email infrastructure, or unique requirements.
But for most teams, authentication is not a core differentiator. They want a solution that is secure, reliable, and easy to integrate-without becoming experts in token hashing, email deliverability, or abuse detection.
A Magic Link SaaS provides:
This allows teams to focus on their product rather than maintaining a bespoke Magic Links identity platform.
Your client token is a secret authentication credential that authorizes your backend to use the Magic Link API. Think of it like an API key-it proves your application is allowed to request and verify magic links.
Include your client token in the Authorization header of all API requests:
Authorization: Bearer YOUR_CLIENT_TOKEN# .env file (DO NOT commit this file!)
MAGIC_LINK_CLIENT_TOKEN=your_actual_token_here
# .env.example file (safe to commit)
MAGIC_LINK_CLIENT_TOKEN=your_token_herehttps://www.lynxidentity.comEndpoint-specific authentication:
/magic-link/request - No auth required (browser-friendly)/magic-link/server-request - Requires mlt_ token (server-to-server only)/magic-link/verify - Requires mlt_ token (server-to-server only)For authenticated endpoints, include your client token in the Authorization header:
Authorization: Bearer mlt_YOUR_CLIENT_TOKENRequest a magic link to be sent to a user's email address. Can be called from browser or server - no authentication required.
Request:
POST /auth/magic-link/request
Content-Type: application/json
{
"email": "user@example.com",
"configuration_id": "cfg_abc123xyz"
}Response (200 OK):
{
"success": true,
"message": "Magic link sent successfully",
"expires_in_seconds": 900
}Server-to-server only: Generate a magic link token WITHOUT sending an email. Use this when you want to send custom emails from your own domain. Requires mlt_ token authentication.
Request:
POST /auth/magic-link/server-request
Content-Type: application/json
Authorization: Bearer mlt_YOUR_CLIENT_TOKEN
{
"email": "user@example.com",
"configuration_id": "cfg_abc123xyz"
}Response (200 OK):
{
"success": true,
"token": "<RAW_MAGIC_LINK_TOKEN>",
"expires_at": "2026-01-04T12:34:56Z"
}Verify a magic link token and authenticate the user.
Request:
GET /auth/magic-link/verify?token=abc123xyz...
Authorization: Bearer YOUR_CLIENT_TOKENResponse (200 OK):
{
"success": true,
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "bearer",
"user_id": "user_550e8400",
"email": "user@example.com",
"redirect_url": "https://yourapp.com/dashboard"
}| Status Code | Error Type | Description |
|---|---|---|
| 400 | Bad Request | Invalid request parameters (missing email, invalid format, etc.) |
| 401 | Unauthorized | Invalid or missing client token |
| 403 | Inactive | Token verification request for an inactive configuration |
| 404 | Not Found | Token doesn't exist or has expired |
| 410 | Gone | Token already used (magic links are single-use only) |
| 429 | Too Many Requests | Rate limit exceeded-try again later |
| 500 | Internal Server Error | Server error-contact support if persistent |
A configuration defines how your magic links behave: expiry time, rate limits, redirect URLs, email templates, and more. You can have multiple configurations for different environments (dev, staging, production).
| Option | Description | Default |
|---|---|---|
| Name | Friendly name to identify this configuration | Required |
| Active Status | Whether this configuration can be used for new requests | Active |
| Token Expiry | How long magic links remain valid (in minutes) | 15 minutes |
| Rate Limit (Email) | Maximum requests per hour per email address | 5/hour |
| Rate Limit (IP) | Maximum requests per hour per IP address | 20/hour |
| Redirect URL | Where to send users after successful authentication | Required |
| User Journey | Flow type: "fastest" (one-click) or "secure" (with confirmation) | fastest |
Your backend handles the secure communication with the Magic Link API.
Create an endpoint in your backend that receives the user's email and requests a magic link.
// Node.js / Express example
app.post('/api/auth/request-magic-link', async (req, res) => {
const { email } = req.body;
if (!email) {
return res.status(400).json({ error: 'Email required' });
}
try {
const response = await fetch('https://www.lynxidentity.com/auth/magic-link/request', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
email: email,
configuration_id: process.env.MAGIC_LINK_CONFIG_ID
})
});
const data = await response.json();
// Always return success for privacy
res.json({ success: true, message: data.message });
} catch (error) {
console.error('Magic link request failed:', error);
res.status(500).json({ error: 'Failed to send magic link' });
}
});Create an endpoint that receives the magic link token and verifies it.
app.get('/api/auth/verify-magic-link', async (req, res) => {
const { token } = req.query;
if (!token) {
return res.status(400).json({ error: 'Token required' });
}
try {
const response = await fetch(
`https://www.lynxidentity.com/auth/magic-link/verify?token=${token}`,
{
headers: {
'Authorization': `Bearer ${process.env.MAGIC_LINK_CLIENT_TOKEN}`
}
}
);
if (!response.ok) {
const error = await response.json();
return res.status(response.status).json(error);
}
const data = await response.json();
// Create your own session
req.session.userId = data.user_id;
req.session.email = data.email;
res.json({
success: true,
redirect_url: data.redirect_url
});
} catch (error) {
console.error('Verification failed:', error);
res.status(500).json({ error: 'Verification failed' });
}
});import React, { useState } from 'react';
function LoginForm() {
const [email, setEmail] = useState('');
const [submitted, setSubmitted] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
const response = await fetch('/api/auth/request-magic-link', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email })
});
if (response.ok) {
setSubmitted(true);
}
};
if (submitted) {
return (
<div>
<h2>Check your email!</h2>
<p>We've sent a magic link to {email}</p>
</div>
);
}
return (
<form onSubmit={handleSubmit}>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Enter your email"
required
/>
<button type="submit">Send Magic Link</button>
</form>
);
}import React, { useEffect, useState } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
function VerifyMagicLink() {
const [status, setStatus] = useState('verifying');
const [searchParams] = useSearchParams();
const navigate = useNavigate();
useEffect(() => {
const token = searchParams.get('token');
if (!token) {
setStatus('error');
return;
}
fetch(`/api/auth/verify-magic-link?token=${token}`)
.then(res => res.json())
.then(data => {
if (data.success) {
setStatus('success');
setTimeout(() => {
window.location.href = data.redirect_url;
}, 1000);
} else {
setStatus('error');
}
})
.catch(() => setStatus('error'));
}, [searchParams]);
return (
<div>
{status === 'verifying' && <p>Verifying your magic link...</p>}
{status === 'success' && <p>Success! Redirecting...</p>}
{status === 'error' && <p>Invalid or expired link</p>}
</div>
);
}For B2B partners who want to send magic links from their own email domain with custom templates.
Use the /magic-link/server-request endpoint to generate a token without sending an email:
// Node.js / Express example
app.post('/api/auth/request-token-for-custom-email', async (req, res) => {
const { email, recipientName } = req.body;
if (!email) {
return res.status(400).json({ error: 'Email required' });
}
try {
// Request magic link token WITHOUT sending email
const response = await fetch('https://www.lynxidentity.com/auth/magic-link/server-request', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.MAGIC_LINK_CLIENT_TOKEN}` // mlt_ token required
},
body: JSON.stringify({
email: email,
configuration_id: process.env.MAGIC_LINK_CONFIG_ID
})
});
if (!response.ok) {
const error = await response.json();
return res.status(response.status).json(error);
}
const data = await response.json();
// data.token contains the RAW magic link token
// ⚠️ CRITICAL: Build the magic link URL and send immediately
// The raw token should ONLY exist in the user's email - never log or store it!
const magicLinkUrl = `https://yourapp.com/verify?token=${data.token}`;
await sendCustomEmail({
to: email,
from: 'noreply@yourcompany.com', // Your domain
subject: 'Your Login Link',
html: `
<h2>Hi ${recipientName || 'there'}!</h2>
<p>Click the link below to log in to YourApp:</p>
<a href="${magicLinkUrl}">Log In Now</a>
<p>This link expires in 15 minutes.</p>
<p>Best regards,<br>The YourApp Team</p>
`
});
// ⚠️ SECURITY: Clear the token from memory immediately after sending
// Do NOT log the token, store it in database, or include in response
res.json({
success: true,
message: 'Custom login email sent'
});
} catch (error) {
console.error('Token generation failed:', error); // Do NOT log data.token!
res.status(500).json({ error: 'Failed to generate token' });
}
});The data.token response contains the raw, unencrypted magic link token. This is the actual authentication credential that will be used to verify the user.
?token={data.token})The raw token should exist in exactly ONE place: the user's email inbox. Once verification completes, the token is consumed and becomes invalid.
# Flask server-to-server custom email integration
from flask import Flask, request, jsonify
import requests
import os
app = Flask(__name__)
@app.route('/api/auth/request-token-for-custom-email', methods=['POST'])
def request_custom_token():
data = request.get_json()
email = data.get('email')
recipient_name = data.get('recipientName', 'there')
if not email:
return jsonify({'error': 'Email required'}), 400
try:
# Request magic link token WITHOUT sending email
response = requests.post(
f"{os.environ['MAGIC_LINK_API_URL']}/auth/magic-link/server-request",
headers={
'Content-Type': 'application/json',
'Authorization': f"Bearer {os.environ['MAGIC_LINK_CLIENT_TOKEN']}"
},
json={
'email': email,
'configuration_id': os.environ['MAGIC_LINK_CONFIG_ID']
}
)
response.raise_for_status()
token_data = response.json()
# token_data['token'] contains the RAW magic link token
# ⚠️ CRITICAL: Build magic link URL and send immediately
magic_link_url = f"https://yourapp.com/verify?token={token_data['token']}"
# Send your own custom email
send_custom_email(
to=email,
from_email='noreply@yourcompany.com',
subject='Your Login Link',
html=f"""
<h2>Hi {recipient_name}!</h2>
<p>Click the link below to log in to YourApp:</p>
<a href="{magic_link_url}">Log In Now</a>
<p>This link expires in 15 minutes.</p>
<p>Best regards,<br>The YourApp Team</p>
"""
)
# ⚠️ SECURITY: Clear token from memory, do NOT log or store it
# The raw token should only exist in the user's email inbox
return jsonify({
'success': True,
'message': 'Custom login email sent'
})
except requests.exceptions.RequestException as e:
# Do NOT log the token_data or include token in error messages
return jsonify({'error': 'Failed to send email'}), 500
def send_custom_email(to, from_email, subject, html):
# Integrate with your email service (SendGrid, Mailgun, etc.)
# Example with SendGrid:
import sendgrid
from sendgrid.helpers.mail import Mail
sg = sendgrid.SendGridAPIClient(api_key=os.environ.get('SENDGRID_API_KEY'))
message = Mail(
from_email=from_email,
to_emails=to,
subject=subject,
html_content=html
)
response = sg.send(message)
return response.status_code == 202When you call /magic-link/server-request, the API returns:
{
"success": true,
"token": "<RAW_MAGIC_LINK_TOKEN>",
"expires_at": "2026-01-04T12:34:56Z"
}The token field contains the actual authentication credential - this is what the user will use to verify their identity. Here's how to handle it securely:
https://yourapp.com/verify?token={RAW_TOKEN}/magic-link/verify to authenticate themWhy this matters: The raw token is like a temporary password. Anyone who has it can authenticate as that user until it expires (15 minutes) or is used. That's why it should exist in only ONE place: the user's email inbox. Our system stores only a cryptographic hash of the token - we never store the raw value, and neither should you.
Complete, production-ready code snippets for common implementations.
// server.js - Full Express.js implementation
require('dotenv').config();
const express = require('express');
const session = require('express-session');
const cors = require('cors');
const app = express();
// Middleware
app.use(express.json());
app.use(cors({
origin: process.env.FRONTEND_URL,
credentials: true
}));
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
}
}));
// Request magic link
app.post('/api/auth/magic-link', async (req, res) => {
const { email } = req.body;
// Validate email
if (!email || !isValidEmail(email)) {
return res.status(400).json({
error: 'Valid email required'
});
}
try {
const response = await fetch(
process.env.MAGIC_LINK_API_URL + '/auth/magic-link/request',
{
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
email: email,
configuration_id: process.env.MAGIC_LINK_CONFIG_ID
})
}
);
if (!response.ok) {
const error = await response.json();
return res.status(response.status).json(error);
}
const data = await response.json();
// Log for monitoring (don't log email in production)
console.log('Magic link requested:', { timestamp: new Date() });
res.json({
success: true,
message: data.message
});
} catch (error) {
console.error('Magic link request failed:', error);
res.status(500).json({
error: 'Failed to send magic link'
});
}
});
// Verify magic link
app.get('/api/auth/verify', async (req, res) => {
const { token } = req.query;
if (!token) {
return res.status(400).json({
error: 'Token required'
});
}
try {
const response = await fetch(
`${process.env.MAGIC_LINK_API_URL}/auth/magic-link/verify?token=${token}`,
{
headers: {
'Authorization': `Bearer ${process.env.MAGIC_LINK_CLIENT_TOKEN}`
}
}
);
if (!response.ok) {
const error = await response.json();
return res.status(response.status).json(error);
}
const data = await response.json();
// Create session
req.session.userId = data.user_id;
req.session.email = data.email;
req.session.loginTime = new Date();
// Log successful login
console.log('User logged in:', {
userId: data.user_id,
timestamp: new Date()
});
res.json({
success: true,
redirect_url: data.redirect_url,
user: {
id: data.user_id,
email: data.email
}
});
} catch (error) {
console.error('Verification failed:', error);
res.status(500).json({
error: 'Verification failed'
});
}
});
// Get current user
app.get('/api/auth/me', (req, res) => {
if (!req.session.userId) {
return res.status(401).json({
error: 'Not authenticated'
});
}
res.json({
userId: req.session.userId,
email: req.session.email,
loginTime: req.session.loginTime
});
});
// Logout
app.post('/api/auth/logout', (req, res) => {
req.session.destroy((err) => {
if (err) {
return res.status(500).json({
error: 'Logout failed'
});
}
res.json({ success: true });
});
});
// Helper function
function isValidEmail(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});// App.jsx - Full React implementation
import React, { useState, useEffect } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
function App() {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Check if user is already logged in
fetch('/api/auth/me', { credentials: 'include' })
.then(res => res.ok ? res.json() : null)
.then(data => {
setUser(data);
setLoading(false);
})
.catch(() => setLoading(false));
}, []);
if (loading) return <div>Loading...</div>;
return (
<BrowserRouter>
<Routes>
<Route path="/login" element={
user ? <Navigate to="/dashboard" /> : <LoginPage setUser={setUser} />
} />
<Route path="/verify" element={<VerifyPage setUser={setUser} />} />
<Route path="/dashboard" element={
user ? <DashboardPage user={user} setUser={setUser} /> : <Navigate to="/login" />
} />
<Route path="/" element={<Navigate to="/dashboard" />} />
</Routes>
</BrowserRouter>
);
}
function LoginPage({ setUser }) {
const [email, setEmail] = useState('');
const [submitted, setSubmitted] = useState(false);
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const response = await fetch('/api/auth/magic-link', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ email })
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to send magic link');
}
setSubmitted(true);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
if (submitted) {
return (
<div className="login-container">
<div className="success-message">
<h2>✓ Check your email!</h2>
<p>We've sent a magic link to <strong>{email}</strong></p>
<p>Click the link to log in instantly.</p>
<button onClick={() => setSubmitted(false)}>
Send another link
</button>
</div>
</div>
);
}
return (
<div className="login-container">
<form onSubmit={handleSubmit}>
<h2>Welcome Back</h2>
<p>Enter your email to receive a magic link</p>
{error && <div className="error">{error}</div>}
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="your@email.com"
required
disabled={loading}
/>
<button type="submit" disabled={loading}>
{loading ? 'Sending...' : 'Send Magic Link'}
</button>
</form>
</div>
);
}
function VerifyPage({ setUser }) {
const [status, setStatus] = useState('verifying');
const [error, setError] = useState('');
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const token = params.get('token');
if (!token) {
setStatus('error');
setError('No token provided');
return;
}
fetch(`/api/auth/verify?token=${token}`, {
credentials: 'include'
})
.then(res => res.json())
.then(data => {
if (data.success) {
setStatus('success');
setUser(data.user);
// Redirect after a short delay
setTimeout(() => {
window.location.href = data.redirect_url || '/dashboard';
}, 1500);
} else {
setStatus('error');
setError(data.error || 'Verification failed');
}
})
.catch(err => {
setStatus('error');
setError('Verification failed');
});
}, [setUser]);
return (
<div className="verify-container">
{status === 'verifying' && (
<div>
<div className="spinner"></div>
<p>Verifying your magic link...</p>
</div>
)}
{status === 'success' && (
<div className="success-message">
<h2>✓ Success!</h2>
<p>You're logged in. Redirecting...</p>
</div>
)}
{status === 'error' && (
<div className="error-message">
<h2>✗ Verification Failed</h2>
<p>{error}</p>
<a href="/login">← Back to login</a>
</div>
)}
</div>
);
}
function DashboardPage({ user, setUser }) {
const handleLogout = async () => {
await fetch('/api/auth/logout', {
method: 'POST',
credentials: 'include'
});
setUser(null);
};
return (
<div className="dashboard">
<h1>Dashboard</h1>
<p>Welcome, {user.email}!</p>
<button onClick={handleLogout}>Logout</button>
</div>
);
}
export default App;# app.py - Flask implementation
from flask import Flask, request, jsonify, session
from flask_cors import CORS
import requests
import os
from datetime import timedelta
app = Flask(__name__)
app.secret_key = os.environ['SESSION_SECRET']
app.config['SESSION_COOKIE_HTTPONLY'] = True
app.config['SESSION_COOKIE_SECURE'] = os.environ.get('ENV') == 'production'
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=7)
CORS(app, supports_credentials=True)
MAGIC_LINK_API = os.environ['MAGIC_LINK_API_URL']
CLIENT_TOKEN = os.environ['MAGIC_LINK_CLIENT_TOKEN']
CONFIG_ID = os.environ['MAGIC_LINK_CONFIG_ID']
@app.route('/api/auth/magic-link', methods=['POST'])
def request_magic_link():
data = request.get_json()
email = data.get('email')
if not email:
return jsonify({'error': 'Email required'}), 400
try:
response = requests.post(
f'{MAGIC_LINK_API}/auth/magic-link/request',
headers={
'Content-Type': 'application/json'
},
json={
'email': email,
'configuration_id': CONFIG_ID
}
)
response.raise_for_status()
return jsonify(response.json())
except requests.exceptions.RequestException as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/auth/verify', methods=['GET'])
def verify_magic_link():
token = request.args.get('token')
if not token:
return jsonify({'error': 'Token required'}), 400
try:
response = requests.get(
f'{MAGIC_LINK_API}/auth/magic-link/verify',
headers={'Authorization': f'Bearer {CLIENT_TOKEN}'},
params={'token': token}
)
if response.status_code == 200:
data = response.json()
# Create session
session.permanent = True
session['user_id'] = data['user_id']
session['email'] = data['email']
return jsonify({
'success': True,
'redirect_url': data['redirect_url'],
'user': {
'id': data['user_id'],
'email': data['email']
}
})
else:
return jsonify(response.json()), response.status_code
except requests.exceptions.RequestException as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/auth/me', methods=['GET'])
def get_current_user():
if 'user_id' not in session:
return jsonify({'error': 'Not authenticated'}), 401
return jsonify({
'user_id': session['user_id'],
'email': session['email']
})
@app.route('/api/auth/logout', methods=['POST'])
def logout():
session.clear()
return jsonify({'success': True})
if __name__ == '__main__':
app.run(port=3000)# .env - Environment configuration template
# Copy this to .env and fill in your values
# Magic Link API Configuration
MAGIC_LINK_API_URL=https://www.lynxidentity.com
MAGIC_LINK_CLIENT_TOKEN=your_client_token_here
MAGIC_LINK_CONFIG_ID=cfg_your_config_id
# Application Configuration
PORT=3000
NODE_ENV=development
FRONTEND_URL=http://localhost:5173
# Session Configuration
SESSION_SECRET=generate_a_secure_random_string_here
# Optional: Database Configuration
DATABASE_URL=postgresql://user:password@localhost:5432/dbname
# Optional: Monitoring/Logging
LOG_LEVEL=infocrypto.randomBytes(32).toString('hex') in Node.js to generate secure session secrets.Visual guide to how magic link authentication works end-to-end.
| Step | Security Measure | Protection Against |
|---|---|---|
| Token Generation | Cryptographically random tokens | Token guessing attacks |
| Token Storage | Hashed in database | Database breaches |
| Token Expiry | 15-minute default lifetime | Stolen/intercepted links |
| Single Use | Token invalidated after verification | Replay attacks |
| HTTPS Only | Encrypted transport | Man-in-the-middle attacks |
| Rate Limiting | Max 5 requests/hour per email | Email bombing, brute force |
| Server-Side Verification | Backend validates all tokens | Client-side tampering |
Common Failure Scenarios: ❌ Token Expired Browser → Server → API API Response: 404 Not Found User Action: Request new magic link ❌ Token Already Used Browser → Server → API API Response: 410 Gone User Action: Request new magic link ❌ Invalid Token Browser → Server → API API Response: 404 Not Found User Action: Check email for correct link ❌ Rate Limited Browser → Server → API API Response: 429 Too Many Requests User Action: Wait before requesting new link ✓ Proper Error Handling: 1. Never expose internal errors to users 2. Log errors for debugging 3. Show friendly, actionable messages 4. Provide clear next steps
Always use HTTPS in production to prevent man-in-the-middle attacks. Magic links sent over HTTP can be intercepted.
The API includes built-in rate limiting, but you should also implement rate limiting in your application:
Always verify magic link tokens on your backend-never trust frontend validation alone. Each token is:
After verifying a magic link, create a secure session:
Problem: Getting 401 errors when making API requests.
Solution:
Problem: Token verification returns 404.
Solution:
Problem: Rate limit exceeded.
Solution:
Problem: User doesn't receive the magic link email.
Solution:
Problem: After verification, redirect doesn't work.
Solution:
If you're still experiencing issues, reach out to our support team with: