Implementing Magic Link Authentication
Implementing Magic Link Authentication
Magic links provide a secure, user-friendly alternative to passwords. This tutorial shows you how to implement magic link authentication in Keycloak, handle edge cases, and create a seamless user experience.
What Are Magic Links?
Magic links are:
- Time-limited, single-use URLs sent to users’ email
- Click to authenticate without entering a password
- Perfect for reducing friction in sign-up/login flows
- More secure than weak passwords
How It Works in Keycloak
Keycloak provides action tokens that we can leverage for magic links:
- User requests magic link
- Generate custom action token
- Send token via email
- Validate token on click
- Create user session
Step 1: Create Custom Authenticator
Magic Link Authenticator SPI
public class MagicLinkAuthenticator implements Authenticator {
private static final Logger logger = Logger.getLogger(MagicLinkAuthenticator.class);
@Override
public void authenticate(AuthenticationFlowContext context) {
// Show email input form
Response response = context.form()
.setAttribute("realm", context.getRealm())
.createForm("magic-link-email.ftl");
context.challenge(response);
}
@Override
public void action(AuthenticationFlowContext context) {
MultivaluedMap<String, String> formData =
context.getHttpRequest().getDecodedFormParameters();
String email = formData.getFirst("email");
if (email == null || email.trim().isEmpty()) {
context.failureChallenge(AuthenticationFlowError.INVALID_USER,
context.form()
.setError("Email is required")
.createForm("magic-link-email.ftl"));
return;
}
// Check if user exists
UserModel user = KeycloakModelUtils.findUserByNameOrEmail(
context.getSession(), context.getRealm(), email);
if (user == null) {
// Optional: Create user if doesn't exist
if (isRegistrationAllowed(context)) {
user = createUser(context, email);
} else {
context.failureChallenge(AuthenticationFlowError.INVALID_USER,
context.form()
.setError("User not found")
.createForm("magic-link-email.ftl"));
return;
}
}
// Generate and send magic link
String token = generateMagicLinkToken(context, user);
sendMagicLink(context, user, token);
// Show success page
Response response = context.form()
.setAttribute("email", email)
.createForm("magic-link-sent.ftl");
context.challenge(response);
}
private String generateMagicLinkToken(AuthenticationFlowContext context, UserModel user) {
int validityInSecs = context.getRealm().getActionTokenGeneratedByUserLifespan();
int absoluteExpirationInSecs = Time.currentTime() + validityInSecs;
// Create custom action token
MagicLinkActionToken token = new MagicLinkActionToken(
user.getId(),
absoluteExpirationInSecs,
context.getAuthenticationSession().getParentSession().getId()
);
// Sign token
String encodedToken = token.serialize(
context.getSession(),
context.getRealm(),
context.getUriInfo()
);
return encodedToken;
}
private void sendMagicLink(AuthenticationFlowContext context,
UserModel user, String token) {
String link = context.getUriInfo().getBaseUriBuilder()
.path("realms")
.path(context.getRealm().getName())
.path("login-actions")
.path("action-token")
.queryParam("key", token)
.queryParam("client_id", context.getAuthenticationSession().getClient().getClientId())
.build()
.toString();
// Send email
try {
context.getSession().getProvider(EmailTemplateProvider.class)
.setRealm(context.getRealm())
.setUser(user)
.send("magicLinkEmail",
"Login to " + context.getRealm().getDisplayName(),
Collections.singletonMap("link", link));
} catch (EmailException e) {
logger.error("Failed to send magic link email", e);
context.failureChallenge(AuthenticationFlowError.INTERNAL_ERROR,
context.form()
.setError("Failed to send email")
.createForm("magic-link-email.ftl"));
}
}
}Magic Link Action Token
@JsonTypeName("magic-link")
public class MagicLinkActionToken extends DefaultActionToken {
public static final String TOKEN_TYPE = "magic-link";
@JsonProperty("sid")
private String sessionId;
public MagicLinkActionToken(String userId, int absoluteExpirationInSecs, String sessionId) {
super(userId, TOKEN_TYPE, absoluteExpirationInSecs, null);
this.sessionId = sessionId;
}
// Required for deserialization
private MagicLinkActionToken() {
}
public String getSessionId() {
return sessionId;
}
public void setSessionId(String sessionId) {
this.sessionId = sessionId;
}
}Action Token Handler
public class MagicLinkActionTokenHandler extends AbstractActionTokenHandler<MagicLinkActionToken> {
@Override
public Response handleToken(MagicLinkActionToken token, ActionTokenContext<MagicLinkActionToken> tokenContext) {
AuthenticationSessionManager authSessionManager = new AuthenticationSessionManager(tokenContext.getSession());
AuthenticationSessionModel authSession = authSessionManager.getAuthenticationSessionByIdAndClient(
tokenContext.getRealm(), token.getSessionId(), tokenContext.getClientSession().getClient());
if (authSession == null) {
return tokenContext.getSession().getProvider(LoginFormsProvider.class)
.setError("Invalid or expired link")
.createErrorPage(Response.Status.BAD_REQUEST);
}
UserModel user = tokenContext.getSession().users()
.getUserById(tokenContext.getRealm(), token.getUserId());
if (user == null || !user.isEnabled()) {
return tokenContext.getSession().getProvider(LoginFormsProvider.class)
.setError("User not found or disabled")
.createErrorPage(Response.Status.BAD_REQUEST);
}
// Set user as authenticated
authSession.setAuthenticatedUser(user);
// Complete authentication
return tokenContext.getSession().getProvider(LoginFormsProvider.class)
.setAuthenticationSession(authSession)
.setSuccess("Login successful")
.createLoginSuccessPage();
}
@Override
public String getDisplayType() {
return "Magic Link";
}
@Override
public String getHelpText() {
return "Handles magic link authentication";
}
}Step 2: Create Email Templates
Email Template (HTML)
<#import "template.ftl" as layout>
<@layout.emailLayout>
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding: 40px 20px;">
<h1 style="margin: 0 0 20px 0; font-size: 24px; line-height: 30px; color: #333333; font-weight: bold;">
Your Magic Link is Here! ✨
</h1>
<p style="margin: 0 0 20px 0; font-size: 16px; line-height: 25px; color: #666666;">
Click the button below to securely log in to ${realmName}.
This link will expire in ${linkExpiration} minutes.
</p>
<!-- Button -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" style="margin: 30px auto;">
<tr>
<td style="border-radius: 8px; background: #007bff;">
<a href="${link}" target="_blank"
style="background: #007bff; border: 15px solid #007bff; font-family: sans-serif; font-size: 16px; line-height: 110%; text-align: center; text-decoration: none; display: block; border-radius: 8px; font-weight: bold;">
<span style="color:#ffffff;">Log In Now</span>
</a>
</td>
</tr>
</table>
<p style="margin: 20px 0; font-size: 14px; line-height: 20px; color: #999999;">
Or copy and paste this link in your browser:
</p>
<p style="margin: 0 0 20px 0; font-size: 14px; line-height: 20px; color: #666666; word-break: break-all;">
${link}
</p>
<!-- Security notice -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-top: 40px;">
<tr>
<td style="padding: 20px; background-color: #f8f9fa; border-radius: 8px;">
<p style="margin: 0; font-size: 14px; color: #666666;">
<strong>🔒 Security Notice:</strong><br>
• This link can only be used once<br>
• It will expire in ${linkExpiration} minutes<br>
• If you didn't request this, please ignore this email
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</@layout.emailLayout>Email Template (Text)
Your Magic Link for ${realmName}
Click the link below to log in:
${link}
This link will expire in ${linkExpiration} minutes and can only be used once.
If you didn't request this login link, please ignore this email.
Security tips:
- Never share this link with anyone
- Make sure you're on the correct website
- The link expires after one use
Step 3: Frontend Implementation
React Component
import React, { useState, useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
const MagicLinkLogin = () => {
const [email, setEmail] = useState('');
const [status, setStatus] = useState('idle'); // idle, sending, sent, error
const [searchParams] = useSearchParams();
useEffect(() => {
// Check if returning from email click
const token = searchParams.get('token');
const error = searchParams.get('error');
if (token) {
handleMagicLinkCallback(token);
} else if (error) {
setStatus('error');
}
}, [searchParams]);
const requestMagicLink = async (e) => {
e.preventDefault();
setStatus('sending');
try {
const response = await fetch('/auth/magic-link', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email })
});
if (response.ok) {
setStatus('sent');
} else {
const error = await response.json();
throw new Error(error.message || 'Failed to send magic link');
}
} catch (error) {
console.error('Magic link error:', error);
setStatus('error');
}
};
const handleMagicLinkCallback = async (token) => {
try {
const response = await fetch('/auth/verify-magic-link', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token })
});
if (response.ok) {
const { redirectUrl } = await response.json();
window.location.href = redirectUrl || '/dashboard';
} else {
setStatus('error');
}
} catch (error) {
console.error('Verification error:', error);
setStatus('error');
}
};
return (
<div className="magic-link-container">
{status === 'idle' || status === 'error' ? (
<form onSubmit={requestMagicLink} className="magic-link-form">
<h2>Sign in with Magic Link</h2>
<p>We'll send you a secure link to sign in instantly.</p>
{status === 'error' && (
<div className="alert alert-error">
Something went wrong. Please try again.
</div>
)}
<div className="form-group">
<label htmlFor="email">Email Address</label>
<input
type="email"
id="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="[email protected]"
required
autoFocus
className="form-control"
/>
</div>
<button
type="submit"
disabled={status === 'sending'}
className="btn btn-primary"
>
{status === 'sending' ? 'Sending...' : 'Send Magic Link'}
</button>
<p className="alternative-login">
Or <a href="/login">sign in with password</a>
</p>
</form>
) : status === 'sent' ? (
<div className="magic-link-sent">
<div className="success-icon">📧</div>
<h2>Check Your Email!</h2>
<p>We sent a magic link to <strong>{email}</strong></p>
<p>Click the link in the email to sign in.</p>
<div className="email-tips">
<h3>Didn't receive it?</h3>
<ul>
<li>Check your spam folder</li>
<li>Make sure you entered the right email</li>
<li>
<button
onClick={() => setStatus('idle')}
className="btn-link"
>
Try again with a different email
</button>
</li>
</ul>
</div>
</div>
) : null}
</div>
);
};
// Styles
const styles = `
.magic-link-container {
max-width: 400px;
margin: 50px auto;
padding: 40px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.magic-link-form h2 {
margin: 0 0 10px 0;
font-size: 24px;
font-weight: 600;
}
.magic-link-sent {
text-align: center;
}
.success-icon {
font-size: 64px;
margin-bottom: 20px;
}
.email-tips {
margin-top: 40px;
padding: 20px;
background: #f8f9fa;
border-radius: 8px;
text-align: left;
}
.email-tips h3 {
margin: 0 0 10px 0;
font-size: 16px;
}
.email-tips ul {
margin: 0;
padding-left: 20px;
}
.btn-link {
background: none;
border: none;
color: #007bff;
text-decoration: underline;
cursor: pointer;
padding: 0;
}
`;Step 4: Backend API Implementation
Node.js/Express Backend
const crypto = require('crypto');
const jwt = require('jsonwebtoken');
// Magic link configuration
const MAGIC_LINK_EXPIRY = 15 * 60; // 15 minutes
const MAGIC_LINK_SECRET = process.env.MAGIC_LINK_SECRET;
// Store used tokens (use Redis in production)
const usedTokens = new Set();
// Request magic link endpoint
app.post('/auth/magic-link', async (req, res) => {
try {
const { email } = req.body;
if (!email || !isValidEmail(email)) {
return res.status(400).json({
error: 'Valid email required'
});
}
// Rate limiting check
if (await isRateLimited(email)) {
return res.status(429).json({
error: 'Too many requests. Please try again later.'
});
}
// Generate secure token
const token = crypto.randomBytes(32).toString('hex');
const payload = {
email,
token,
type: 'magic-link',
exp: Math.floor(Date.now() / 1000) + MAGIC_LINK_EXPIRY
};
const signedToken = jwt.sign(payload, MAGIC_LINK_SECRET);
// Store token hash for validation
await storeToken(email, crypto.createHash('sha256').update(token).digest('hex'));
// Generate link
const magicLink = `${process.env.APP_URL}/auth/magic-link?token=${signedToken}`;
// Send email
await sendMagicLinkEmail(email, magicLink, MAGIC_LINK_EXPIRY / 60);
// Log for security monitoring
logger.info('Magic link requested', {
email,
ip: req.ip,
userAgent: req.get('user-agent')
});
res.json({
message: 'Magic link sent',
expiresIn: MAGIC_LINK_EXPIRY
});
} catch (error) {
logger.error('Magic link error', error);
res.status(500).json({
error: 'Failed to send magic link'
});
}
});
// Verify magic link endpoint
app.post('/auth/verify-magic-link', async (req, res) => {
try {
const { token } = req.body;
if (!token) {
return res.status(400).json({
error: 'Token required'
});
}
// Verify JWT
let payload;
try {
payload = jwt.verify(token, MAGIC_LINK_SECRET);
} catch (error) {
return res.status(401).json({
error: 'Invalid or expired token'
});
}
// Check if token was already used
const tokenHash = crypto.createHash('sha256').update(payload.token).digest('hex');
if (usedTokens.has(tokenHash)) {
return res.status(401).json({
error: 'Token already used'
});
}
// Validate stored token
const storedHash = await getStoredToken(payload.email);
if (storedHash !== tokenHash) {
return res.status(401).json({
error: 'Invalid token'
});
}
// Mark token as used
usedTokens.add(tokenHash);
await deleteStoredToken(payload.email);
// Get or create user in Keycloak
const keycloakToken = await authenticateUserWithKeycloak(payload.email);
// Create session
req.session.accessToken = keycloakToken.access_token;
req.session.refreshToken = keycloakToken.refresh_token;
req.session.email = payload.email;
// Log successful login
logger.info('Magic link login successful', {
email: payload.email,
ip: req.ip
});
res.json({
success: true,
redirectUrl: '/dashboard'
});
} catch (error) {
logger.error('Magic link verification error', error);
res.status(500).json({
error: 'Verification failed'
});
}
});
// Helper functions
async function authenticateUserWithKeycloak(email) {
// Exchange magic link for Keycloak tokens
const response = await fetch(`${KEYCLOAK_URL}/realms/${REALM}/protocol/openid-connect/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'password', // Use service account
client_id: ADMIN_CLIENT_ID,
client_secret: ADMIN_CLIENT_SECRET,
username: 'service-account',
password: SERVICE_ACCOUNT_PASSWORD
})
});
const adminToken = await response.json();
// Create user session programmatically
// This is a simplified version - implement according to your needs
return createUserSession(email, adminToken.access_token);
}
// Email sending
async function sendMagicLinkEmail(email, link, expiryMinutes) {
const mailOptions = {
from: '"Your App" <[email protected]>',
to: email,
subject: '🔐 Your Magic Sign-In Link',
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2>Sign in to Your App</h2>
<p>Click the button below to sign in instantly:</p>
<div style="text-align: center; margin: 30px 0;">
<a href="${link}"
style="background-color: #007bff; color: white; padding: 12px 30px; text-decoration: none; border-radius: 5px; display: inline-block;">
Sign In Now
</a>
</div>
<p style="color: #666; font-size: 14px;">
This link expires in ${expiryMinutes} minutes and can only be used once.
</p>
<p style="color: #666; font-size: 14px;">
If you didn't request this, please ignore this email.
</p>
</div>
`,
text: `Sign in to Your App\n\nClick here to sign in: ${link}\n\nThis link expires in ${expiryMinutes} minutes.`
};
await transporter.sendMail(mailOptions);
}
// Rate limiting
const loginAttempts = new Map();
async function isRateLimited(email) {
const key = `magic-link:${email}`;
const attempts = loginAttempts.get(key) || [];
const now = Date.now();
// Remove old attempts (older than 1 hour)
const recentAttempts = attempts.filter(time => now - time < 3600000);
// Allow max 3 attempts per hour
if (recentAttempts.length >= 3) {
return true;
}
recentAttempts.push(now);
loginAttempts.set(key, recentAttempts);
return false;
}Step 5: Security Enhancements
Token Security
// Secure token generation
function generateSecureToken() {
// Use crypto.randomBytes for cryptographically secure tokens
const token = crypto.randomBytes(32).toString('base64url');
// Add timestamp to prevent replay attacks
const timestamp = Date.now();
const payload = `${token}.${timestamp}`;
// Sign the payload
const signature = crypto
.createHmac('sha256', process.env.TOKEN_SECRET)
.update(payload)
.digest('base64url');
return `${payload}.${signature}`;
}
// Token validation with timing attack prevention
function validateSecureToken(fullToken, maxAgeMs = 900000) { // 15 minutes
const parts = fullToken.split('.');
if (parts.length !== 3) return false;
const [token, timestamp, providedSignature] = parts;
const payload = `${token}.${timestamp}`;
// Verify signature (constant-time comparison)
const expectedSignature = crypto
.createHmac('sha256', process.env.TOKEN_SECRET)
.update(payload)
.digest('base64url');
if (!crypto.timingSafeEqual(
Buffer.from(providedSignature),
Buffer.from(expectedSignature)
)) {
return false;
}
// Check age
const age = Date.now() - parseInt(timestamp);
if (age > maxAgeMs) return false;
return true;
}Prevent Email Enumeration
// Always return same response regardless of email existence
app.post('/auth/magic-link', async (req, res) => {
const { email } = req.body;
// Always return success
res.json({
message: 'If an account exists with this email, a magic link has been sent.'
});
// Process async to prevent timing attacks
setImmediate(async () => {
try {
const user = await findUserByEmail(email);
if (user) {
await sendMagicLink(email);
}
} catch (error) {
logger.error('Magic link processing error', error);
}
});
});Device Fingerprinting
// Add device fingerprinting for additional security
function generateDeviceFingerprint(req) {
const components = [
req.get('user-agent'),
req.get('accept-language'),
req.get('accept-encoding'),
req.ip
];
return crypto
.createHash('sha256')
.update(components.join('|'))
.digest('hex');
}
// Store and validate device fingerprints
app.post('/auth/verify-magic-link', async (req, res) => {
const currentFingerprint = generateDeviceFingerprint(req);
const storedFingerprint = await getStoredFingerprint(payload.token);
if (currentFingerprint !== storedFingerprint) {
// New device - might require additional verification
logger.warn('Magic link used from different device', {
email: payload.email,
originalDevice: storedFingerprint,
currentDevice: currentFingerprint
});
// Optional: Require additional verification
if (STRICT_DEVICE_CHECK) {
return res.status(401).json({
error: 'Security check failed. Please request a new link.'
});
}
}
});Step 6: Advanced Features
Remember Me Option
// Extended session for trusted devices
app.post('/auth/magic-link', async (req, res) => {
const { email, rememberMe } = req.body;
const tokenExpiry = rememberMe ?
30 * 24 * 60 * 60 : // 30 days
15 * 60; // 15 minutes
// Include in token payload
const payload = {
email,
rememberMe,
exp: Math.floor(Date.now() / 1000) + tokenExpiry
};
});Multi-Factor Authentication
// Require additional factor after magic link
app.post('/auth/verify-magic-link', async (req, res) => {
// ... verify magic link ...
const user = await getUser(payload.email);
if (user.mfaEnabled) {
// Don't complete login yet
req.session.pendingMFA = {
userId: user.id,
email: user.email
};
return res.json({
requiresMFA: true,
mfaType: user.mfaType,
redirectUrl: '/auth/mfa'
});
}
// Complete login for non-MFA users
completeLogin(user, req, res);
});Analytics and Monitoring
// Track magic link metrics
const metrics = {
requested: new promClient.Counter({
name: 'magic_links_requested_total',
help: 'Total magic links requested',
labelNames: ['status']
}),
verified: new promClient.Counter({
name: 'magic_links_verified_total',
help: 'Total magic links verified',
labelNames: ['status', 'device_match']
}),
latency: new promClient.Histogram({
name: 'magic_link_verification_duration_seconds',
help: 'Time to verify magic link',
buckets: [0.1, 0.5, 1, 2, 5]
})
};
// Track in endpoints
metrics.requested.inc({ status: 'success' });
metrics.verified.inc({
status: 'success',
device_match: fingerprint === stored ? 'true' : 'false'
});Best Practices
1. Email Delivery
- Use transactional email service (SendGrid, AWS SES)
- Set proper SPF, DKIM, DMARC records
- Monitor delivery rates
- Provide clear sender name
2. User Experience
- Show clear success/error messages
- Provide alternative login methods
- Handle email delivery delays gracefully
- Support “Resend” functionality
3. Security
- Single-use tokens only
- Short expiration times (10-15 minutes)
- Rate limit requests per email
- Log all authentication attempts
- Implement device verification for sensitive apps
4. Testing
describe('Magic Link Authentication', () => {
it('should send magic link for valid email', async () => {
const response = await request(app)
.post('/auth/magic-link')
.send({ email: '[email protected]' });
expect(response.status).toBe(200);
expect(mockEmailService.send).toHaveBeenCalledWith(
expect.objectContaining({
to: '[email protected]',
subject: expect.stringContaining('Magic')
})
);
});
it('should reject used tokens', async () => {
const token = generateTestToken();
// First use - should succeed
await request(app)
.post('/auth/verify-magic-link')
.send({ token })
.expect(200);
// Second use - should fail
await request(app)
.post('/auth/verify-magic-link')
.send({ token })
.expect(401);
});
});Common Issues and Solutions
Email Delivery Problems
// Implement retry logic
async function sendMagicLinkWithRetry(email, link, maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
try {
await sendEmail(email, link);
return;
} catch (error) {
logger.warn(`Email send attempt ${i + 1} failed`, error);
if (i === maxRetries - 1) throw error;
await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, i)));
}
}
}Token Expiration Handling
// Graceful expiration handling
app.post('/auth/verify-magic-link', async (req, res) => {
try {
// ... verify token ...
} catch (error) {
if (error.name === 'TokenExpiredError') {
return res.status(401).json({
error: 'Link expired',
code: 'LINK_EXPIRED',
suggestion: 'Request a new magic link'
});
}
throw error;
}
});