Magic Link Authentication

A "magic link" is a passwordless authentication method that provides a secure, frictionless login experience. Instead of entering a password, users receive a unique, time-sensitive link via email that automatically logs them in when clicked.

Benefits

Improved Security: No passwords to steal, reuse, or forget
Better User Experience: One-click login without password entry
Reduced Support Burden: Eliminates "forgot password" flows
Email Verification: Confirms email ownership as part of authentication
Phishing Resistance: Time-limited, one-time links are harder to exploit

Common Use Cases

  • Primary Authentication: Replace traditional username/password login
  • Email Verification: Verify user email addresses during signup
  • Password Reset: Secure alternative to traditional password reset flows
  • Secure Actions: Confirm sensitive operations (account changes, purchases)
  • Guest Access: Temporary access without account creation

Implementation Overview

This guide demonstrates how to implement a secure magic link flow using DyLy's JWT links with the OAuth 2.0-style authorization code flow.

Architecture

The magic link flow involves these components:

  1. Frontend Client: Where the user initiates login (web app, mobile app)
  2. Backend Server: Creates and validates links, manages user sessions
  3. DyLy Service: Generates secure JWT links and handles redirection
  4. Email Service: Delivers the magic link to the user

Security Features

This implementation includes:

  • ✅ JWT signatures for tamper-proof links
  • ✅ Authorization code flow (not exposing JWTs directly)
  • ✅ PKCE for public clients (mobile apps, SPAs)
  • ✅ State validation for CSRF protection
  • ✅ One-time use links
  • ✅ Key protection for unpredictable URLs
  • ✅ Short expiration times

Step-by-Step Implementation

When a user requests to log in, your backend creates a JWT link containing user identification claims.

API Request:

POST https://dyly-api.lilacwells.com/url/api/v1/links
Content-Type: application/json
Authorization: Basic YOUR_CREDENTIAL

{
  "type": "jwt",
  "projectId": "{your-project-id}",
  "jwtClaims": {
    "sub": "<user-id>",
    "email": "user@example.com",
    "aud": "your-app-name",
    "nonce": "cryptographically-random-nonce"
  },
  "jwtExpiresIn": 300,
  "keyProtected": true,
  "oneTime": true,
  "expiresIn": 3600,
  "flow": "code",
  "clientType": "public",
  "destinationUrl": "https://yourapp.com/auth/callback"
}

Parameter Explanations:

  • sub (Subject): The unique identifier for the user attempting to log in
  • email: User's email address (for verification and audit)
  • aud (Audience): Your application identifier (prevents JWT misuse)
  • nonce: Random value to prevent replay attacks (generate with crypto library)
  • jwtExpiresIn: JWT validity in seconds (5 minutes recommended)
  • keyProtected: Adds a secret key parameter to the URL
  • oneTime: Link can only be used once
  • expiresIn: Link expiration in seconds (1 hour allows time for email delivery)
  • flow: Use "code" for secure authorization code flow
  • clientType: "public" for SPAs/mobile, "confidential" for server-side apps
  • destinationUrl: Where to redirect after clicking the link

Response:

{
  "projectId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "destinationUrl": "https://yourapp.com/auth/callback",
  "path": "feGjryu",
  "jwtClaims": {
    "sub": "user-12345",
    "email": "user@example.com",
    "aud": "your-app-name",
    "nonce": "abc123xyz"
  },
  "shortUrl": "https://myapp.dyly.dev/feGjryu?key=fdgre5447467ygrf34rgfg543y2fgdhj5",
  "alias": "bXlwcmlqZWN0LmR5bHkuYXBwIy53ZWxsLWtub3duL29wZW5pZC1jb25maWd1cmF0aW9u",
  "expiresIn": 3600,
  "jwtExpiresIn": 300,
  "createdAt": "2025-07-04T00:26:18.666Z",
  "oneTime": true,
  "type": "jwt",
  "keyProtected": true,
  "flow": "code",
  "codeVerifier": "save-this-securely-abc123..."
}

Important: Save the codeVerifier - you'll need it to exchange the authorization code for the JWT.

Step 2: Store Code Verifier

Your backend must securely store the codeVerifier associated with this authentication attempt.

Storage Options:

Option 1: Database (recommended for confidential clients)

await db.magicLinks.create({
  userId: user.id,
  alias: response.alias,
  codeVerifier: response.codeVerifier,
  nonce: response.jwtClaims.nonce,
  createdAt: new Date(),
  expiresAt: new Date(Date.now() + 3600000)
});

Option 2: Encrypted Session (for public clients)

// Store in encrypted session storage (client-side)
const encryptedData = await encrypt({
  codeVerifier: response.codeVerifier,
  nonce: response.jwtClaims.nonce,
  alias: response.alias
});
sessionStorage.setItem('magicLinkData', encryptedData);

Option 3: In-Memory Cache (Redis, Memcached)

await redis.setex(
  `magic-link:${response.alias}`,
  3600,
  JSON.stringify({
    codeVerifier: response.codeVerifier,
    nonce: response.jwtClaims.nonce,
    userId: user.id
  })
);

Send the shortUrl to the user's email address.

Email Template Example:

<!DOCTYPE html>
<html>
<head>
  <title>Login to YourApp</title>
</head>
<body>
  <h1>Click to Log In</h1>
  <p>You requested to log in to YourApp. Click the button below to continue:</p>
  
  <a href="{{shortUrl}}" style="background: #0066cc; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px;">
    Log In to YourApp
  </a>
  
  <p><small>This link will expire in 1 hour and can only be used once.</small></p>
  
  <p><small>If you didn't request this, please ignore this email.</small></p>
  
  <p><small>For security, never share this link with anyone.</small></p>
</body>
</html>

Security Tips for Email:

  • ⚠️ Use HTTPS for all links
  • ⚠️ Clearly state the link expires and is one-time use
  • ⚠️ Include your company name/logo to prevent phishing
  • ⚠️ Warn users never to share the link
  • ⚠️ Consider including a "Didn't request this?" message

When the user clicks the link, several things happen automatically:

  1. Browser requests https://myapp.dyly.dev/feGjryu?key=...
  2. DyLy validates the link (checks expiration, key, one-time status)
  3. DyLy sets a session cookie for CSRF protection
  4. DyLy generates an authorization code (one-time use)
  5. DyLy redirects to https://yourapp.com/auth/callback?code=xyz&state=abc

Redirect URL Format:

https://yourapp.com/auth/callback?code={authorization-code}&state={csrf-state}

The state parameter is crucial for CSRF protection in the next step.

Step 5: Validate State (CSRF Protection)

Your client-side application must validate the state parameter before proceeding.

Why State Validation?

  • Prevents CSRF attacks where attackers trick users into logging in as the attacker
  • Ensures the redirect came from the same session that initiated the flow
  • Provides click metadata (IP, user agent, timestamp) for security auditing

Client-Side Implementation (must run in browser with cookies):

// Extract parameters from redirect URL
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');
const state = urlParams.get('state');
const alias = 'alias-from-step-2'; // Retrieved from storage
const clientId = 'your-client-id';

// Call state validation API (must include cookies)
const response = await fetch(
  `https://myapp.dyly.dev/state-validation?state=${state}&alias=${alias}&clientId=${clientId}`,
  {
    method: 'GET',
    credentials: 'include' // Critical: includes session cookie
  }
);

const validation = await response.json();

if (validation.cookieValidation !== 'VALID') {
  // CSRF attack detected or session mismatch
  throw new Error('Invalid authentication state');
}

// Optional: Check metadata for suspicious activity
console.log('Link clicked at:', validation.clickedAt);
console.log('User agent:', validation.userAgent);
console.log('IP address:', validation.ipAddress);

Response Example:

{
  "cookieValidation": "VALID",
  "clickedAt": "2025-07-07T07:53:24.704Z",
  "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)...",
  "ipAddress": "192.168.1.1"
}

Validation Results:

  • VALID: State matches session cookie - safe to proceed
  • INVALID: State doesn't match - possible CSRF attack

Critical: State validation MUST be called from the browser where the cookie exists. Server-side calls will fail because the cookie won't be present.

Mobile App Considerations:

For mobile apps, ensure the HTTP client:

  • Accepts and stores cookies
  • Includes cookies in subsequent requests
  • Uses a webview that shares cookies with the state validation request

Step 6: Exchange Authorization Code for JWT

Your backend uses the authorization code to request the actual JWT from DyLy.

API Request (for confidential clients with client secret):

POST https://dyly-api.lilacwells.com/url/api/v1/links/{alias}/jwt
Content-Type: application/x-www-form-urlencoded

code={authorization-code}&client_id={clientId}&clientType=confidential&codeVerifier={saved-codeVerifier}&clientSecret={your-client-secret}

API Request (for public clients with PKCE):

POST https://dyly-api.lilacwells.com/url/api/v1/links/{alias}/jwt
Content-Type: application/x-www-form-urlencoded

code={authorization-code}&client_id={clientId}&clientType=public&codeVerifier={saved-codeVerifier}

Parameters:

  • code: The authorization code from the redirect
  • client_id: Your DyLy client ID
  • clientType: "confidential" (with secret) or "public" (PKCE only)
  • codeVerifier: The verifier saved in Step 2
  • clientSecret: Your client secret (confidential clients only)

Response:

{
  "jwt": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyLTEyMzQ1IiwiZW1haWwiOiJ1c2VyQGV4YW1wbGUuY29tIiwiYXVkIjoieW91ci1hcHAtbmFtZSIsIm5vbmNlIjoiYWJjMTIzeHl6IiwiaXNzIjoibXlhcHAuZHlseS5kZXYiLCJpYXQiOjE3MzU4OTQ0MDQsImV4cCI6MTczNTg5NDcwNCwibmJmIjoxNzM1ODk0NDA0LCJqdGkiOiJ1bmlxdWUtand0LWlkIn0.signature...",
  "jwksUri": "https://myapp.dyly.dev/.well-known/jwks.json"
}

Error Handling:

try {
  const response = await fetch(jwtEndpoint, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      code: authCode,
      client_id: clientId,
      clientType: 'confidential',
      codeVerifier: savedVerifier,
      clientSecret: clientSecret
    })
  });

  if (!response.ok) {
    const error = await response.json();
    if (response.status === 404) {
      // Link expired or already used
      throw new Error('Magic link expired or already used');
    } else if (response.status === 401) {
      // Invalid credentials
      throw new Error('Invalid client credentials');
    } else {
      throw new Error('Failed to obtain JWT');
    }
  }

  const { jwt, jwksUri } = await response.json();
  // Proceed to validation...
} catch (error) {
  // Handle error appropriately
  console.error('JWT exchange failed:', error);
}

Step 7: Validate the JWT

Before trusting any data in the JWT, your backend must verify its signature and claims.

JWT Validation Checklist:

  1. ✅ Verify signature using public key from JWKS
  2. ✅ Check iss (issuer) matches your domain
  3. ✅ Check exp (expiration) hasn't passed
  4. ✅ Check nbf (not before) has passed
  5. ✅ Check aud (audience) matches your app
  6. ✅ Verify custom claims (sub, email, nonce)

Example Implementation (Node.js with jsonwebtoken):

const jwt = require('jsonwebtoken');
const jwksClient = require('jwks-rsa');

// Configure JWKS client
const client = jwksClient({
  jwksUri: 'https://myapp.dyly.dev/.well-known/jwks.json',
  cache: true,
  cacheMaxAge: 86400000 // 24 hours
});

// Get signing key
function getKey(header, callback) {
  client.getSigningKey(header.kid, (err, key) => {
    if (err) return callback(err);
    const signingKey = key.publicKey || key.rsaPublicKey;
    callback(null, signingKey);
  });
}

// Verify JWT
jwt.verify(
  receivedJWT,
  getKey,
  {
    issuer: 'myapp.dyly.dev',
    audience: 'your-app-name',
    algorithms: ['RS256', 'ES256']
  },
  (err, decoded) => {
    if (err) {
      console.error('JWT verification failed:', err);
      return;
    }

    // Additional claim validation
    if (!decoded.sub || !decoded.email) {
      throw new Error('Missing required claims');
    }

    // Verify nonce matches what we stored
    if (decoded.nonce !== storedNonce) {
      throw new Error('Nonce mismatch - possible replay attack');
    }

    // JWT is valid - create user session
    createUserSession(decoded.sub, decoded.email);
  }
);

Python Example (using PyJWT):

import jwt
from jwt import PyJWKClient

# Fetch JWKS
jwks_client = PyJWKClient('https://myapp.dyly.dev/.well-known/jwks.json')
signing_key = jwks_client.get_signing_key_from_jwt(received_jwt)

# Verify JWT
try:
    decoded = jwt.decode(
        received_jwt,
        signing_key.key,
        algorithms=['RS256', 'ES256'],
        issuer='myapp.dyly.dev',
        audience='your-app-name',
        options={'verify_exp': True}
    )
    
    # Verify nonce
    if decoded['nonce'] != stored_nonce:
        raise ValueError('Nonce mismatch')
    
    # Create user session
    create_user_session(decoded['sub'], decoded['email'])
    
except jwt.ExpiredSignatureError:
    print('JWT has expired')
except jwt.InvalidTokenError as e:
    print(f'Invalid JWT: {e}')

Step 8: Establish User Session

After successful JWT validation, create a session for the user.

async function createUserSession(userId, email) {
  // Load user from database
  const user = await db.users.findOne({ id: userId, email: email });
  
  if (!user) {
    throw new Error('User not found');
  }

  // Create session
  const session = await db.sessions.create({
    userId: user.id,
    createdAt: new Date(),
    expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
    ipAddress: req.ip,
    userAgent: req.headers['user-agent']
  });

  // Set session cookie
  res.cookie('sessionId', session.id, {
    httpOnly: true,
    secure: true,
    sameSite: 'strict',
    maxAge: 7 * 24 * 60 * 60 * 1000
  });

  // Clean up: Delete used magic link data
  await db.magicLinks.delete({ userId: userId, alias: linkAlias });

  return session;
}

Complete Flow Diagram

Magic Link Sequence Diagram

This sequence diagram shows the complete magic link authentication flow from start to finish, including all interactions between the user, your application, DyLy, and email service.

Security Considerations

Threat Model & Mitigations

Threat: Email Interception

  • Risk: Attacker intercepts the email containing the magic link
  • Mitigation: Short expiration times (5-10 minutes), one-time use
  • Additional: Monitor for suspicious login patterns (unusual locations/devices)

Threat: Link Sharing

  • Risk: User accidentally shares the link with someone else
  • Mitigation: One-time use, key protection, short expiration
  • Additional: User education, clear warnings in email

Threat: Phishing

  • Risk: Attacker sends fake magic link emails
  • Mitigation: Educate users on legitimate domains, use branded emails
  • Additional: Implement DMARC/SPF/DKIM for email authentication

Threat: CSRF

  • Risk: Attacker tricks user into authenticating as the attacker
  • Mitigation: State validation with session cookies
  • Additional: Verify IP/user agent consistency (heuristic)

Threat: Replay Attack

  • Risk: Attacker reuses a previously valid authorization code or JWT
  • Mitigation: One-time authorization codes, nonce validation
  • Additional: Track used nonces, short JWT expiration

Threat: Session Hijacking

  • Risk: Attacker steals user's session after authentication
  • Mitigation: Secure session cookies (httpOnly, secure, sameSite)
  • Additional: Session rotation, device fingerprinting, IP validation

Best Practices

Defense in Depth: Use multiple security layers (oneTime + keyProtected + short expiration + state validation)
Minimal JWT Lifetime: 5 minutes is usually sufficient
Link Expiration: 10-60 minutes allows email delivery while limiting exposure
Nonce Validation: Always verify nonce to prevent replay attacks
Audit Logging: Log all authentication attempts for security monitoring
Rate Limiting: Limit magic link requests per email address (e.g., 3 per hour)
Secure Sessions: Use httpOnly, secure, sameSite cookies
Email Security: Implement SPF, DKIM, DMARC for your email domain

Configuration Recommendations

Production Configuration:

{
  "jwtExpiresIn": 300,      // 5 minutes
  "expiresIn": 1800,        // 30 minutes
  "oneTime": true,          // Must be true
  "keyProtected": true,     // Highly recommended
  "flow": "code",           // Always use code flow
  "clientType": "confidential" // Or "public" with PKCE
}

Development Configuration (more lenient for testing):

{
  "jwtExpiresIn": 900,      // 15 minutes
  "expiresIn": 3600,        // 1 hour
  "oneTime": true,
  "keyProtected": true,
  "flow": "code",
  "clientType": "public"
}

Troubleshooting Guide

Common Issues

Issue: "State validation returns INVALID"

Possible Causes:

  • State validation called from server instead of browser (no cookie)
  • Cookies blocked by browser settings or extensions
  • Session expired between click and validation

Solutions:

  • ✅ Ensure state validation is called from browser with credentials: 'include'
  • ✅ Check browser console for cookie warnings
  • ✅ Verify your domain and DyLy domain are properly configured
  • ✅ Test with a fresh browser session
  • ✅ Consider using IP/UA metadata as fallback (less secure)

Issue: "JWT exchange returns 404"

Possible Causes:

  • Link already used (one-time link consumed)
  • Link expired
  • Invalid authorization code
  • Wrong alias used

Solutions:

  • ✅ Check link expiration settings
  • ✅ Verify authorization code is fresh (not reused)
  • ✅ Ensure alias matches the link created
  • ✅ Implement retry mechanism (request new magic link)

Issue: "JWT signature verification fails"

Possible Causes:

  • Wrong JWKS URL
  • Clock skew between systems
  • JWT expired
  • Corrupted JWT

Solutions:

  • ✅ Verify JWKS URL matches your domain: https://<your-domain>/.well-known/jwks.json
  • ✅ Check system clocks are synchronized (use NTP)
  • ✅ Inspect JWT claims at jwt.io (DO NOT paste real JWTs on public sites!)
  • ✅ Ensure JWT hasn't expired (check exp claim)

Issue: "Users not receiving magic link emails"

Possible Causes:

  • Email marked as spam
  • Email delivery delays
  • Wrong email address
  • Email service issues

Solutions:

  • ✅ Implement SPF, DKIM, DMARC for your domain
  • ✅ Use reputable email service (SendGrid, AWS SES, etc.)
  • ✅ Check spam folders
  • ✅ Verify email address is correct
  • ✅ Monitor email delivery logs
  • ✅ Provide a "resend link" option

Issue: "Link expired before user could click it"

Possible Causes:

  • Link expiration too short
  • Email delivery delays
  • User took too long to check email

Solutions:

  • ✅ Increase expiresIn to 30-60 minutes
  • ✅ Keep jwtExpiresIn short (5-10 minutes) for security
  • ✅ Provide clear expiration time in email
  • ✅ Offer "resend link" functionality
  • ✅ Monitor email delivery time metrics

Issue: "Mobile app doesn't preserve session cookie"

Possible Causes:

  • HTTP client doesn't support cookies
  • Cookie cleared between requests
  • Webview and app don't share cookie storage

Solutions:

  • ✅ Use a webview for OAuth flow (inherits browser cookies)
  • ✅ Configure HTTP client to accept and store cookies
  • ✅ Consider fallback to IP/UA validation (less secure)
  • ✅ Test cookie persistence in your app

Testing Checklist

Before deploying to production, test these scenarios:

Happy Path

  • ✅ User requests magic link
  • ✅ Email is received within reasonable time (< 1 minute)
  • ✅ Link works on first click
  • ✅ User is successfully authenticated
  • ✅ Session is created correctly

Security Tests

  • ✅ Link cannot be used twice (one-time validation)
  • ✅ Expired links return appropriate error
  • ✅ State validation detects CSRF attempts
  • ✅ JWT signature verification works
  • ✅ Invalid nonce is rejected

Error Handling

  • ✅ Expired link shows user-friendly error
  • ✅ Used link shows appropriate message
  • ✅ Invalid state shows security warning
  • ✅ Network errors are handled gracefully
  • ✅ User can request a new link easily

Cross-Browser/Device

  • ✅ Works in Chrome, Firefox, Safari, Edge
  • ✅ Works on iOS devices
  • ✅ Works on Android devices
  • ✅ Works in mobile apps (if applicable)
  • ✅ Cookies work across all platforms

Performance

  • ✅ Link creation takes < 500ms
  • ✅ Email delivery takes < 60 seconds
  • ✅ Authentication completes < 2 seconds after click
  • ✅ System handles concurrent requests

Additional Resources

Next Steps

This approach using a JWT link ensures that the link has been clicked and allows you to obtain signed data (JWT), making it applicable to a wide range of use cases beyond authentication. For example, you can send a link for invitations or when updating user information, and easily implement a flow that proceeds only if the link has been reliably clicked.