How to Add Passwordless Login to Your Website in 2025

How to Add Passwordless Login to Your Website in 2025: Complete Implementation Guide

Passwordless authentication delivers 3x higher login success rates and 4x faster sign-ins compared to traditional passwords. This comprehensive tutorial provides step-by-step implementation guidance, complete code examples, library recommendations, and security best practices for adding magic links, WebAuthn passkeys, or hybrid passwordless systems to your website in 2025.

Alice Test
Alice Test
November 27, 2025 · 12 min read

Choosing Your Passwordless Approach

Try MagicAuth

Experience the technology discussed in this article.

Learn More →

Before implementing passwordless authentication, you need to choose which method best fits your application and users. In 2025, three primary approaches dominate:

1. Magic Links (Email-Based Authentication)

Best for: Consumer applications, B2C platforms, mobile-first services

Advantages:

  • Works on any device with email access
  • No special hardware or software requirements
  • Familiar user experience (email verification)
  • Simple implementation with existing email infrastructure
  • Cross-device authentication support

Challenges:

  • Dependency on email delivery speed and reliability
  • Email account security becomes critical single point of failure
  • Requires internet connectivity for email access

2. WebAuthn Passkeys (Biometric/Hardware)

Best for: Enterprise applications, security-critical platforms, modern browser-only services

Advantages:

  • Strongest security through cryptographic keys
  • Phishing-resistant authentication
  • Fast authentication (typically 2-5 seconds)
  • No email dependency
  • Biometric convenience (fingerprint, face recognition)

Challenges:

  • Requires modern devices with biometric capabilities or security keys
  • More complex implementation
  • Account recovery requires careful planning
  • Browser compatibility considerations

3. Hybrid Approach

Best for: Applications serving diverse user bases

Many successful implementations use passkeys as primary method with magic links as fallback, ensuring accessibility while encouraging the most secure option.

Implementing Magic Link Authentication

Let's start with magic link implementation—the most accessible passwordless approach. We'll build a complete system from scratch.

Frontend Implementation

First, create a simple login form that only requests email:

<!-- Login Form -->
<form id="login-form">
  <input
    type="email"
    id="email"
    placeholder="Enter your email"
    required
  />
  <button type="submit">Send Magic Link</button>
</form>

<div id="status-message" style="display: none;"></div>

<script>
document.getElementById('login-form').addEventListener('submit', async (e) => {
  e.preventDefault();

  const email = document.getElementById('email').value;
  const statusDiv = document.getElementById('status-message');

  try {
    const response = await fetch('/api/auth/send-magic-link', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email })
    });

    if (response.ok) {
      statusDiv.textContent = 'Check your email for the login link!';
      statusDiv.style.display = 'block';
      statusDiv.style.color = 'green';
    } else {
      throw new Error('Failed to send magic link');
    }
  } catch (error) {
    statusDiv.textContent = 'Error sending link. Please try again.';
    statusDiv.style.display = 'block';
    statusDiv.style.color = 'red';
  }
});
</script>

Backend Implementation (Node.js/Express)

Now implement the server-side logic for generating and validating magic links:

const express = require('express');
const crypto = require('crypto');
const nodemailer = require('nodemailer');

const app = express();
app.use(express.json());

// In-memory token storage (use Redis/database in production)
const magicTokens = new Map();

// Email transporter (configure with your email service)
const transporter = nodemailer.createTransport({
  service: 'gmail',
  auth: {
    user: process.env.EMAIL_USER,
    pass: process.env.EMAIL_PASSWORD
  }
});

// Generate magic link endpoint
app.post('/api/auth/send-magic-link', async (req, res) => {
  const { email } = req.body;

  // Validate email exists in your database
  // const user = await findUserByEmail(email);
  // if (!user) return res.status(404).json({ error: 'User not found' });

  // Generate cryptographically secure token
  const token = crypto.randomBytes(32).toString('hex');

  // Store token with metadata
  magicTokens.set(token, {
    email,
    createdAt: Date.now(),
    expiresAt: Date.now() + (15 * 60 * 1000), // 15 minutes
    used: false
  });

  // Construct magic link
  const magicLink = `${process.env.BASE_URL}/auth/verify?token=${token}`;

  // Send email
  await transporter.sendMail({
    from: process.env.EMAIL_USER,
    to: email,
    subject: 'Your Login Link',
    html: `
      <h2>Login to Your Account</h2>
      <p>Click the link below to log in:</p>
      <a href="${magicLink}">Log In</a>
      <p>This link expires in 15 minutes.</p>
      <p>If you didn't request this, please ignore this email.</p>
    `
  });

  res.json({ success: true });
});

// Verify magic link endpoint
app.get('/auth/verify', async (req, res) => {
  const { token } = req.query;

  // Validate token exists
  const tokenData = magicTokens.get(token);
  if (!tokenData) {
    return res.status(400).send('Invalid or expired link');
  }

  // Check expiration
  if (Date.now() > tokenData.expiresAt) {
    magicTokens.delete(token);
    return res.status(400).send('Link has expired');
  }

  // Check single-use
  if (tokenData.used) {
    return res.status(400).send('Link has already been used');
  }

  // Mark token as used
  tokenData.used = true;

  // Create session (implementation depends on your session management)
  req.session = {
    userId: tokenData.email, // Use actual user ID from database
    createdAt: Date.now()
  };

  // Redirect to application
  res.redirect('/dashboard');
});

app.listen(3000, () => console.log('Server running on port 3000'));

Production-Ready Enhancements

The basic implementation above works, but production systems need additional features:

Rate Limiting

const rateLimit = require('express-rate-limit');

const magicLinkLimiter = rateLimit({
  windowMs: 60 * 60 * 1000, // 1 hour
  max: 3, // 3 requests per hour per IP
  message: 'Too many login attempts. Please try again later.'
});

app.post('/api/auth/send-magic-link', magicLinkLimiter, async (req, res) => {
  // ... implementation
});

Token Storage in Redis

const redis = require('redis');
const client = redis.createClient();

// Store token in Redis with automatic expiration
await client.setex(
  `magic_token:${token}`,
  900, // 15 minutes in seconds
  JSON.stringify(tokenData)
);

// Retrieve and validate
const storedData = await client.get(`magic_token:${token}`);
if (!storedData) {
  return res.status(400).send('Invalid or expired link');
}

Implementing WebAuthn Passkeys

WebAuthn implementation is more complex but provides stronger security. We'll use the @passwordless-id/webauthn library to simplify the process.

Frontend Setup

<!-- Include the library -->
<script type="module">
import { client } from "https://cdn.jsdelivr.net/npm/@passwordless-id/webauthn@1.4.0/dist/webauthn.min.js"

// Registration flow
async function registerPasskey() {
  // Get challenge from server
  const challengeResponse = await fetch('/api/auth/passkey/challenge');
  const { challenge } = await challengeResponse.json();

  // Create passkey
  const registration = await client.register({
    user: "user@example.com",
    challenge: challenge,
    userVerification: "required" // Require biometric/PIN
  });

  // Send registration to server
  await fetch('/api/auth/passkey/register', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(registration)
  });

  alert('Passkey registered successfully!');
}

// Authentication flow
async function loginWithPasskey() {
  // Get challenge from server
  const challengeResponse = await fetch('/api/auth/passkey/challenge');
  const { challenge } = await challengeResponse.json();

  // Authenticate with passkey
  const authentication = await client.authenticate({
    challenge: challenge,
    userVerification: "required"
  });

  // Verify with server
  const verifyResponse = await fetch('/api/auth/passkey/verify', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(authentication)
  });

  if (verifyResponse.ok) {
    window.location.href = '/dashboard';
  }
}
</script>

<button onclick="registerPasskey()">Register Passkey</button>
<button onclick="loginWithPasskey()">Login with Passkey</button>

Backend Implementation

const { server } = require('@passwordless-id/webauthn');
const crypto = require('crypto');

// Store passkeys (use database in production)
const passkeys = new Map();
const challenges = new Map();

// Generate challenge endpoint
app.get('/api/auth/passkey/challenge', (req, res) => {
  const challenge = crypto.randomBytes(32).toString('base64');

  // Store challenge temporarily (5 minute expiration)
  challenges.set(challenge, {
    createdAt: Date.now(),
    expiresAt: Date.now() + (5 * 60 * 1000)
  });

  res.json({ challenge });
});

// Register passkey endpoint
app.post('/api/auth/passkey/register', async (req, res) => {
  const registration = req.body;

  // Verify registration
  const expected = {
    challenge: async (challenge) => {
      const challengeData = challenges.get(challenge);
      if (!challengeData || Date.now() > challengeData.expiresAt) {
        return false;
      }
      challenges.delete(challenge);
      return true;
    },
    origin: process.env.BASE_URL
  };

  try {
    const registrationParsed = await server.verifyRegistration(
      registration,
      expected
    );

    // Store passkey
    passkeys.set(registrationParsed.credential.id, {
      userId: registration.user,
      publicKey: registrationParsed.credential.publicKey,
      algorithm: registrationParsed.credential.algorithm
    });

    res.json({ success: true });
  } catch (error) {
    res.status(400).json({ error: error.message });
  }
});

// Verify passkey authentication
app.post('/api/auth/passkey/verify', async (req, res) => {
  const authentication = req.body;

  // Get stored passkey
  const passkeyData = passkeys.get(authentication.credentialId);
  if (!passkeyData) {
    return res.status(400).json({ error: 'Unknown passkey' });
  }

  // Verify authentication
  const expected = {
    challenge: async (challenge) => {
      const challengeData = challenges.get(challenge);
      if (!challengeData || Date.now() > challengeData.expiresAt) {
        return false;
      }
      challenges.delete(challenge);
      return true;
    },
    origin: process.env.BASE_URL,
    userVerified: true
  };

  try {
    await server.verifyAuthentication(
      authentication,
      passkeyData,
      expected
    );

    // Create session
    req.session = {
      userId: passkeyData.userId,
      createdAt: Date.now()
    };

    res.json({ success: true });
  } catch (error) {
    res.status(400).json({ error: error.message });
  }
});

Using Authentication Libraries

For production applications, consider using established authentication libraries that handle complexity:

Popular Passwordless Libraries for 2025

1. Passwordless.dev

Comprehensive passwordless solution with both magic links and passkeys:

// Installation
npm install @passwordlessdev/passwordless-client

// Usage
import { PasswordlessClient } from '@passwordlessdev/passwordless-client';

const client = new PasswordlessClient({
  apiKey: 'your-public-api-key'
});

// Register passkey
const token = await fetch('/get-registration-token').then(r => r.text());
await client.register(token, 'user@example.com');

2. Auth0 Passwordless

Enterprise-grade authentication with passwordless support:

// Installation
npm install auth0-js

// Configure
const auth0 = new auth0.WebAuth({
  domain: 'your-domain.auth0.com',
  clientID: 'your-client-id'
});

// Send magic link
auth0.passwordlessStart({
  connection: 'email',
  send: 'link',
  email: 'user@example.com'
}, callback);

3. Clerk

Modern authentication with built-in passwordless:

// Installation
npm install @clerk/clerk-react

// Usage in React
import { SignIn } from "@clerk/clerk-react";

function LoginPage() {
  return <SignIn />;
}

Security Best Practices

Regardless of implementation approach, follow these security guidelines:

Token Security

  • Use cryptographically secure random generation: Always use crypto.randomBytes() or equivalent, never Math.random()
  • Minimum 256-bit tokens: 32 bytes provides sufficient entropy to prevent brute force
  • Short expiration: 10-15 minutes maximum for magic links
  • Single-use enforcement: Invalidate tokens immediately after use
  • Secure storage: Use Redis or database with proper expiration, never in-memory for production

Email Security

  • SPF, DKIM, DMARC: Configure email authentication to prevent spoofing
  • TLS for delivery: Ensure email service provider uses TLS
  • Clear messaging: Help users identify legitimate emails
  • Security warnings: Include "ignore if you didn't request this" message

WebAuthn Security

  • Origin validation: Verify registration and authentication origins match your domain
  • Challenge validation: Ensure challenges are used only once within expiration window
  • User verification: Require biometric or PIN for sensitive operations
  • Attestation: For high-security applications, verify authenticator attestation

General Security

  • Rate limiting: Prevent abuse through excessive authentication attempts
  • Session management: Implement proper session timeouts and revocation
  • Account recovery: Provide secure fallback when passwordless methods fail
  • Monitoring: Log authentication attempts and alert on suspicious patterns
  • HTTPS enforcement: All authentication flows must use HTTPS

Testing Your Implementation

Thorough testing ensures your passwordless system works reliably:

Functional Testing

// Example Jest tests for magic link authentication

describe('Magic Link Authentication', () => {
  test('should send magic link email', async () => {
    const response = await fetch('/api/auth/send-magic-link', {
      method: 'POST',
      body: JSON.stringify({ email: 'test@example.com' })
    });

    expect(response.ok).toBe(true);
  });

  test('should reject expired tokens', async () => {
    // Create expired token
    const expiredToken = 'test-expired-token';

    const response = await fetch(`/auth/verify?token=${expiredToken}`);

    expect(response.status).toBe(400);
  });

  test('should reject reused tokens', async () => {
    // Use token once
    await fetch(`/auth/verify?token=test-token`);

    // Try to use again
    const response = await fetch(`/auth/verify?token=test-token`);

    expect(response.status).toBe(400);
  });
});

Browser Compatibility Testing

Test WebAuthn implementations across browsers:

  • Chrome/Edge (Chromium): Generally excellent support
  • Safari: Good support on macOS/iOS with iCloud Keychain
  • Firefox: Full WebAuthn support
  • Mobile browsers: Test on actual devices, not just emulators

User Experience Considerations

Technical implementation is only half the battle. Great user experience drives adoption:

Clear Communication

  • Explain what passwordless authentication is and why it's better
  • Provide "Check your email" confirmation immediately after request
  • Show helpful errors: "Link expired" vs "Unknown error"
  • Include troubleshooting tips: "Check spam folder"

Progressive Enhancement

  • Offer passwordless alongside traditional authentication during transition
  • Encourage passwordless through UI design without forcing it
  • Provide clear migration path for existing users

Mobile Optimization

  • Design for mobile-first since many users will authenticate on phones
  • Test email client rendering across providers
  • Ensure magic link buttons are large enough for easy tapping
  • Support biometric authentication on mobile devices

Deployment Checklist

Before launching passwordless authentication:

  • ✅ Configure production email service (SendGrid, Mailgun, SES)
  • ✅ Set up email authentication (SPF, DKIM, DMARC)
  • ✅ Implement rate limiting on all authentication endpoints
  • ✅ Configure Redis or database for token storage
  • ✅ Set up monitoring and alerting for authentication failures
  • ✅ Test account recovery flows
  • ✅ Prepare user education materials
  • ✅ Test on multiple devices and browsers
  • ✅ Implement session management
  • ✅ Configure HTTPS and verify SSL certificates
  • ✅ Set up logging for security audits
  • ✅ Create fallback authentication method

Conclusion: Your Passwordless Future

Implementing passwordless authentication in 2025 has never been easier. With mature libraries, comprehensive browser support, and proven implementation patterns, you can deliver authentication that's both more secure and more user-friendly than traditional passwords.

Start with the approach that fits your users: magic links for broad accessibility, WebAuthn passkeys for maximum security, or a hybrid approach for flexibility. Follow security best practices, test thoroughly, and communicate clearly with users.

The passwordless future is here. The question isn't whether to implement it, but when. With this guide, you have everything needed to add passwordless login to your website today.

MagicAuth Blog
MagicAuth Blog

Insights on passwordless authentication

More from this blog →

Responses

No responses yet. Be the first to share your thoughts!