All articles
Authentication Security Backend Node.js

Passwordless Authentication Authentication Strategies for Modern Web Applications

Palakorn Voramongkol
April 12, 2025 12 min read

“A comprehensive implementation guide to passwordless authentication — covering magic email links, SMS OTP, WebAuthn/FIDO2 passkeys, implementation patterns, and security considerations.”

Deep Dive: Passwordless Authentication

What is Passwordless Authentication?

Passwordless authentication is an approach that verifies user identity without requiring a password. Instead of a shared secret that users must remember, passwordless systems rely on possession factors (something the user has — email inbox, phone, hardware key) or inherence factors (something the user is — fingerprint, face). The user proves their identity by demonstrating control over a device or channel, rather than recalling a memorized string.

The three dominant passwordless methods are magic email links (a signed URL sent to the user’s inbox), SMS/email one-time passwords (OTP), and WebAuthn/FIDO2 passkeys (cryptographic credentials stored on-device). Each offers a different balance of security, convenience, and implementation complexity.

Passwordless gained momentum after the FIDO Alliance published the WebAuthn standard in 2019, and adoption accelerated rapidly when Apple, Google, and Microsoft committed to passkey support across their platforms in 2022-2023.

Core Principles

  • No shared secrets: There is no password to steal, phish, or brute-force. The credential never crosses the network (WebAuthn) or is single-use and time-limited (magic links, OTP).
  • Possession-based verification: Authentication is tied to a device or account the user controls (email, phone, hardware key).
  • Phishing resistance: WebAuthn/FIDO2 passkeys are inherently phishing-resistant — the credential is bound to the origin and cannot be replayed on a different domain.
  • Reduced support burden: No password resets, no complexity requirements, no “forgot password” flows.
sequenceDiagram
    participant U as User
    participant B as Browser
    participant S as Server
    participant E as Email Service
    participant DB as Database

    U->>B: Enter email address
    B->>S: POST /auth/magic-link {email}
    S->>S: Generate signed token (JWT, 15min expiry)
    S->>DB: Store token hash + email + expiry
    S->>E: Send email with magic link URL
    E-->>U: Email: "Click to sign in"
    S-->>B: "Check your email"

    U->>B: Click magic link (/auth/verify?token=...)
    B->>S: GET /auth/verify?token=abc123
    S->>DB: Lookup token hash, check expiry
    DB-->>S: Valid — email: user@example.com
    S->>DB: Delete used token (single-use)
    S->>S: Create session or issue JWT
    S-->>B: Set-Cookie / return tokens — Logged in

Now let’s understand why passwords remain problematic and how each passwordless approach solves this.

The Password Problem

Before exploring solutions, let’s understand why passwords remain problematic despite decades of security advice:

Credential Stuffing & Reuse: A 2023 Verizon Data Breach Investigations Report found that 49% of breaches involved stolen credentials. When one service is compromised, attackers try those same credentials everywhere. Humans simply cannot manage 100+ unique, complex passwords.

Phishing Vulnerability: Passwords are user-aware credentials. Users can be socially engineered into entering them on malicious sites. A sophisticated phishing attack is nearly impossible to defend against when the credential is a shared secret.

Brute Force Complexity: Enforcing password policies (length, complexity, rotation) creates friction without proportional security gains. Users respond by choosing weaker passwords or reusing them.

Credential Storage Risk: Services must hash passwords correctly. Any storage breach with weak hashing exposes users to offline cracking. Even well-designed password hashing uses computational resources that scale poorly.

Passwordless authentication sidesteps all these issues by making the authentication factor something a user possesses (phone, email) or something they are (biometric).

Magic links are the most friction-free passwordless approach: users request a link via email, click it, and they’re authenticated. The secret is that these links are cryptographically signed tokens valid for a short window.

  1. User enters their email
  2. Application generates a signed token with an expiry
  3. Email is sent containing a link with the token
  4. User clicks the link
  5. Application validates the token signature and expiry
  6. Session is created

Production Implementation

Here’s a complete, production-quality implementation using Express.js and TypeScript:

import crypto from 'crypto';
import { Router, Request, Response } from 'express';
import jwt from 'jsonwebtoken';
import nodemailer from 'nodemailer';
import { prisma } from '@/lib/prisma';
import { logger } from '@/lib/logger';

// Configuration
const MAGIC_LINK_SECRET = process.env.MAGIC_LINK_SECRET!;
const MAGIC_LINK_EXPIRY = 15 * 60 * 1000; // 15 minutes
const MAX_REQUESTS_PER_HOUR = 5;
const BASE_URL = process.env.BASE_URL!;

// Email configuration
const transporter = nodemailer.createTransport({
  host: process.env.SMTP_HOST,
  port: parseInt(process.env.SMTP_PORT || '587'),
  secure: process.env.SMTP_SECURE === 'true',
  auth: {
    user: process.env.SMTP_USER,
    pass: process.env.SMTP_PASS,
  },
});

interface MagicLinkPayload {
  email: string;
  iat: number;
  exp: number;
}

/**
 * Rate limiter for magic link requests
 * Prevents abuse by limiting requests per email per hour
 */
async function checkRateLimit(email: string): Promise<boolean> {
  const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000);
  
  const recentAttempts = await prisma.magicLinkAttempt.count({
    where: {
      email: email.toLowerCase(),
      createdAt: { gte: oneHourAgo },
    },
  });

  return recentAttempts < MAX_REQUESTS_PER_HOUR;
}

/**
 * Record a magic link attempt for rate limiting
 */
async function recordMagicLinkAttempt(email: string): Promise<void> {
  await prisma.magicLinkAttempt.create({
    data: {
      email: email.toLowerCase(),
      createdAt: new Date(),
    },
  });
}

/**
 * Generate a cryptographically signed magic link token
 */
function generateMagicLinkToken(email: string): string {
  const payload: MagicLinkPayload = {
    email: email.toLowerCase(),
    iat: Math.floor(Date.now() / 1000),
    exp: Math.floor(Date.now() / 1000) + MAGIC_LINK_EXPIRY / 1000,
  };

  return jwt.sign(payload, MAGIC_LINK_SECRET, {
    algorithm: 'HS256',
  });
}

/**
 * Verify a magic link token and extract the email
 */
function verifyMagicLinkToken(token: string): { email: string } | null {
  try {
    const decoded = jwt.verify(token, MAGIC_LINK_SECRET, {
      algorithms: ['HS256'],
    }) as MagicLinkPayload;

    return { email: decoded.email };
  } catch (error) {
    logger.warn('Invalid magic link token', { error });
    return null;
  }
}

/**
 * Send magic link email
 */
async function sendMagicLinkEmail(
  email: string,
  token: string
): Promise<void> {
  const magicLinkUrl = `${BASE_URL}/auth/callback?token=${encodeURIComponent(token)}`;

  const htmlContent = `
    <h2>Your Magic Link</h2>
    <p>Click the link below to sign in to your account:</p>
    <p>
      <a href="${magicLinkUrl}" style="background: #0066cc; color: white; padding: 10px 20px; text-decoration: none; border-radius: 4px;">
        Sign In
      </a>
    </p>
    <p>Or copy and paste this link in your browser:</p>
    <p><code>${magicLinkUrl}</code></p>
    <p>This link expires in 15 minutes.</p>
    <p>If you didn't request this link, you can safely ignore this email.</p>
  `;

  try {
    await transporter.sendMail({
      from: process.env.SMTP_FROM!,
      to: email,
      subject: 'Your Magic Sign-In Link',
      html: htmlContent,
      text: `Sign in here: ${magicLinkUrl}\n\nThis link expires in 15 minutes.`,
    });

    logger.info('Magic link email sent', { email });
  } catch (error) {
    logger.error('Failed to send magic link email', { email, error });
    throw new Error('Failed to send email. Please try again.');
  }
}

/**
 * Express route to request a magic link
 */
export async function requestMagicLink(req: Request, res: Response) {
  const { email } = req.body;

  // Validation
  if (!email || typeof email !== 'string') {
    return res.status(400).json({ error: 'Email is required' });
  }

  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  if (!emailRegex.test(email)) {
    return res.status(400).json({ error: 'Invalid email format' });
  }

  try {
    // Check rate limit
    const withinLimit = await checkRateLimit(email);
    if (!withinLimit) {
      return res.status(429).json({
        error: 'Too many requests. Please try again in an hour.',
      });
    }

    // Record attempt for rate limiting
    await recordMagicLinkAttempt(email);

    // Generate token
    const token = generateMagicLinkToken(email);

    // Send email
    await sendMagicLinkEmail(email, token);

    // Return success (don't leak whether email exists)
    return res.status(200).json({
      message: 'Check your email for a sign-in link',
    });
  } catch (error) {
    logger.error('Error requesting magic link', { error });
    return res.status(500).json({
      error: 'An error occurred. Please try again.',
    });
  }
}

/**
 * Express route to verify magic link and create session
 */
export async function verifyMagicLink(req: Request, res: Response) {
  const { token } = req.query;

  if (!token || typeof token !== 'string') {
    return res.status(400).json({ error: 'Token is required' });
  }

  // Verify token
  const decoded = verifyMagicLinkToken(token);
  if (!decoded) {
    return res.status(401).json({
      error: 'Invalid or expired link. Please request a new one.',
    });
  }

  try {
    // Find or create user
    let user = await prisma.user.findUnique({
      where: { email: decoded.email },
    });

    if (!user) {
      user = await prisma.user.create({
        data: {
          email: decoded.email,
          name: decoded.email.split('@')[0],
        },
      });
    }

    // Create session
    const sessionToken = crypto.randomBytes(32).toString('hex');
    await prisma.session.create({
      data: {
        token: sessionToken,
        userId: user.id,
        expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days
      },
    });

    // Set secure, httpOnly cookie
    res.cookie('sessionToken', sessionToken, {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'lax',
      maxAge: 30 * 24 * 60 * 60 * 1000,
      path: '/',
    });

    logger.info('User authenticated via magic link', { userId: user.id });

    return res.status(200).json({
      message: 'Authenticated successfully',
      user: { id: user.id, email: user.email, name: user.name },
    });
  } catch (error) {
    logger.error('Error verifying magic link', { error });
    return res.status(500).json({
      error: 'An error occurred. Please try again.',
    });
  }
}
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.*;
import org.springframework.web.bind.annotation.*;
import java.security.SecureRandom;
import java.time.*;
import java.util.*;

@RestController
@RequestMapping("/auth")
public class MagicLinkController {

    private static final int MAGIC_LINK_EXPIRY_MINUTES = 15;
    private static final int MAX_REQUESTS_PER_HOUR = 5;

    @Autowired private UserRepository userRepo;
    @Autowired private SessionRepository sessionRepo;
    @Autowired private MagicLinkAttemptRepository attemptRepo;
    @Autowired private JavaMailSender mailSender;
    @Value("${magic.link.secret}") private String secret;
    @Value("${app.base-url}") private String baseUrl;

    private boolean checkRateLimit(String email) {
        Instant oneHourAgo = Instant.now().minusSeconds(3600);
        long count = attemptRepo.countByEmailAndCreatedAtAfter(
            email.toLowerCase(), oneHourAgo);
        return count < MAX_REQUESTS_PER_HOUR;
    }

    private String generateMagicLinkToken(String email) {
        return Jwts.builder()
            .subject(email.toLowerCase())
            .issuedAt(new Date())
            .expiration(Date.from(Instant.now().plusSeconds(MAGIC_LINK_EXPIRY_MINUTES * 60)))
            .signWith(Keys.hmacShaKeyFor(secret.getBytes()))
            .compact();
    }

    private Optional<String> verifyMagicLinkToken(String token) {
        try {
            Claims claims = Jwts.parser()
                .verifyWith(Keys.hmacShaKeyFor(secret.getBytes()))
                .build()
                .parseSignedClaims(token)
                .getPayload();
            return Optional.of(claims.getSubject());
        } catch (JwtException e) {
            return Optional.empty();
        }
    }

    private void sendMagicLinkEmail(String email, String token) {
        String magicLinkUrl = baseUrl + "/auth/callback?token=" + token;
        SimpleMailMessage msg = new SimpleMailMessage();
        msg.setTo(email);
        msg.setSubject("Your Magic Sign-In Link");
        msg.setText("Sign in here: " + magicLinkUrl + "\n\nThis link expires in 15 minutes.");
        mailSender.send(msg);
    }

    @PostMapping("/magic-link")
    public ResponseEntity<Map<String, String>> requestMagicLink(
            @RequestBody Map<String, String> body) {
        String email = body.get("email");
        if (email == null || !email.matches("^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$")) {
            return ResponseEntity.badRequest()
                .body(Map.of("error", "Valid email is required"));
        }

        if (!checkRateLimit(email)) {
            return ResponseEntity.status(429)
                .body(Map.of("error", "Too many requests. Please try again in an hour."));
        }

        attemptRepo.save(new MagicLinkAttempt(email.toLowerCase()));
        String token = generateMagicLinkToken(email);
        sendMagicLinkEmail(email, token);

        return ResponseEntity.ok(Map.of("message", "Check your email for a sign-in link"));
    }

    @GetMapping("/callback")
    public ResponseEntity<Map<String, Object>> verifyMagicLink(
            @RequestParam String token,
            HttpServletResponse response) {
        Optional<String> emailOpt = verifyMagicLinkToken(token);
        if (emailOpt.isEmpty()) {
            return ResponseEntity.status(401)
                .body(Map.of("error", "Invalid or expired link. Please request a new one."));
        }

        String email = emailOpt.get();
        User user = userRepo.findByEmail(email).orElseGet(() ->
            userRepo.save(User.builder()
                .email(email)
                .name(email.split("@")[0])
                .build()));

        SecureRandom sr = new SecureRandom();
        byte[] tokenBytes = new byte[32];
        sr.nextBytes(tokenBytes);
        String sessionToken = HexFormat.of().formatHex(tokenBytes);

        sessionRepo.save(Session.builder()
            .token(sessionToken)
            .userId(user.getId())
            .expiresAt(Instant.now().plus(30, ChronoUnit.DAYS))
            .build());

        ResponseCookie cookie = ResponseCookie.from("sessionToken", sessionToken)
            .httpOnly(true).secure(true).sameSite("Lax")
            .maxAge(Duration.ofDays(30)).path("/").build();
        response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());

        return ResponseEntity.ok(Map.of(
            "message", "Authenticated successfully",
            "user", Map.of("id", user.getId(), "email", user.getEmail())));
    }
}
import hashlib
import secrets
import re
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from fastapi.responses import JSONResponse
from pydantic import BaseModel, EmailStr
import jwt
from sqlalchemy.orm import Session

router = APIRouter()

MAGIC_LINK_SECRET = settings.MAGIC_LINK_SECRET
MAGIC_LINK_EXPIRY_MINUTES = 15
MAX_REQUESTS_PER_HOUR = 5
BASE_URL = settings.BASE_URL


def check_rate_limit(db: Session, email: str) -> bool:
    one_hour_ago = datetime.now(timezone.utc) - timedelta(hours=1)
    count = (db.query(MagicLinkAttempt)
        .filter(MagicLinkAttempt.email == email.lower(),
                MagicLinkAttempt.created_at >= one_hour_ago)
        .count())
    return count < MAX_REQUESTS_PER_HOUR


def generate_magic_link_token(email: str) -> str:
    now = datetime.now(timezone.utc)
    payload = {
        "email": email.lower(),
        "iat": int(now.timestamp()),
        "exp": int((now + timedelta(minutes=MAGIC_LINK_EXPIRY_MINUTES)).timestamp()),
    }
    return jwt.encode(payload, MAGIC_LINK_SECRET, algorithm="HS256")


def verify_magic_link_token(token: str) -> dict | None:
    try:
        return jwt.decode(token, MAGIC_LINK_SECRET, algorithms=["HS256"])
    except jwt.PyJWTError:
        return None


async def send_magic_link_email(email: str, token: str) -> None:
    magic_link_url = f"{BASE_URL}/auth/callback?token={token}"
    # Use your email provider (SendGrid, SES, etc.)
    await email_service.send(
        to=email,
        subject="Your Magic Sign-In Link",
        html=f'<a href="{magic_link_url}">Sign In</a> — expires in 15 minutes.',
        text=f"Sign in here: {magic_link_url}\n\nThis link expires in 15 minutes.",
    )


class MagicLinkRequest(BaseModel):
    email: EmailStr


@router.post("/auth/magic-link")
async def request_magic_link(
    body: MagicLinkRequest,
    db: Session = Depends(get_db),
):
    if not check_rate_limit(db, body.email):
        raise HTTPException(status_code=429,
            detail="Too many requests. Please try again in an hour.")

    db.add(MagicLinkAttempt(email=body.email.lower()))
    db.commit()

    token = generate_magic_link_token(body.email)
    await send_magic_link_email(body.email, token)

    return {"message": "Check your email for a sign-in link"}


@router.get("/auth/callback")
async def verify_magic_link(
    token: str,
    response: Response,
    db: Session = Depends(get_db),
):
    decoded = verify_magic_link_token(token)
    if not decoded:
        raise HTTPException(status_code=401,
            detail="Invalid or expired link. Please request a new one.")

    email = decoded["email"]
    user = db.query(User).filter_by(email=email).first()
    if not user:
        user = User(email=email, name=email.split("@")[0])
        db.add(user)
        db.commit()

    session_token = secrets.token_hex(32)
    db.add(Session(
        token=session_token,
        user_id=user.id,
        expires_at=datetime.now(timezone.utc) + timedelta(days=30),
    ))
    db.commit()

    response.set_cookie("sessionToken", session_token,
        httponly=True, secure=True, samesite="lax",
        max_age=30 * 24 * 60 * 60, path="/")

    return {"message": "Authenticated successfully",
            "user": {"id": user.id, "email": user.email, "name": user.name}}
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using Microsoft.IdentityModel.Tokens;

[ApiController]
[Route("auth")]
public class MagicLinkController : ControllerBase
{
    private const int MagicLinkExpiryMinutes = 15;
    private const int MaxRequestsPerHour = 5;

    private readonly AppDbContext _db;
    private readonly IEmailService _email;
    private readonly IConfiguration _config;

    public MagicLinkController(AppDbContext db, IEmailService email,
        IConfiguration config)
    { _db = db; _email = email; _config = config; }

    private async Task<bool> CheckRateLimitAsync(string email)
    {
        var oneHourAgo = DateTimeOffset.UtcNow.AddHours(-1);
        var count = await _db.MagicLinkAttempts.CountAsync(a =>
            a.Email == email.ToLower() && a.CreatedAt >= oneHourAgo);
        return count < MaxRequestsPerHour;
    }

    private string GenerateMagicLinkToken(string email)
    {
        var key = new SymmetricSecurityKey(
            Encoding.UTF8.GetBytes(_config["MagicLink:Secret"]!));
        var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
        var token = new JwtSecurityToken(
            claims: new[] { new Claim(ClaimTypes.Email, email.ToLower()) },
            expires: DateTime.UtcNow.AddMinutes(MagicLinkExpiryMinutes),
            signingCredentials: creds);
        return new JwtSecurityTokenHandler().WriteToken(token);
    }

    private ClaimsPrincipal? VerifyMagicLinkToken(string token)
    {
        try
        {
            var handler = new JwtSecurityTokenHandler();
            return handler.ValidateToken(token, new TokenValidationParameters
            {
                ValidateIssuerSigningKey = true,
                IssuerSigningKey = new SymmetricSecurityKey(
                    Encoding.UTF8.GetBytes(_config["MagicLink:Secret"]!)),
                ValidateIssuer = false,
                ValidateAudience = false,
                ClockSkew = TimeSpan.Zero,
            }, out _);
        }
        catch { return null; }
    }

    [HttpPost("magic-link")]
    public async Task<IActionResult> RequestMagicLink(
        [FromBody] MagicLinkRequest body)
    {
        if (!await CheckRateLimitAsync(body.Email))
            return StatusCode(429, new { error = "Too many requests. Please try again in an hour." });

        _db.MagicLinkAttempts.Add(new MagicLinkAttempt {
            Email = body.Email.ToLower(),
            CreatedAt = DateTimeOffset.UtcNow,
        });
        await _db.SaveChangesAsync();

        var token = GenerateMagicLinkToken(body.Email);
        var magicLinkUrl = $"{_config["App:BaseUrl"]}/auth/callback?token={Uri.EscapeDataString(token)}";
        await _email.SendAsync(body.Email, "Your Magic Sign-In Link",
            $"<a href='{magicLinkUrl}'>Sign In</a> — expires in 15 minutes.");

        return Ok(new { message = "Check your email for a sign-in link" });
    }

    [HttpGet("callback")]
    public async Task<IActionResult> VerifyMagicLink([FromQuery] string token)
    {
        var principal = VerifyMagicLinkToken(token);
        if (principal == null)
            return Unauthorized(new { error = "Invalid or expired link. Please request a new one." });

        var email = principal.FindFirstValue(ClaimTypes.Email)!;
        var user = await _db.Users.FirstOrDefaultAsync(u => u.Email == email)
            ?? _db.Users.Add(new User {
                Email = email, Name = email.Split('@')[0]
            }).Entity;
        await _db.SaveChangesAsync();

        var sessionToken = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLower();
        _db.Sessions.Add(new Session {
            Token = sessionToken, UserId = user.Id,
            ExpiresAt = DateTimeOffset.UtcNow.AddDays(30),
        });
        await _db.SaveChangesAsync();

        Response.Cookies.Append("sessionToken", sessionToken, new CookieOptions {
            HttpOnly = true, Secure = true, SameSite = SameSiteMode.Lax,
            MaxAge = TimeSpan.FromDays(30), Path = "/",
        });

        return Ok(new { message = "Authenticated successfully",
            user = new { user.Id, user.Email, user.Name } });
    }
}

Database Schema

// Prisma schema
model User {
  id        String   @id @default(cuid())
  email     String   @unique
  name      String?
  sessions  Session[]
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

model Session {
  id        String   @id @default(cuid())
  token     String   @unique
  userId    String
  user      User     @relation(fields: [userId], references: [id], onDelete: Cascade)
  expiresAt DateTime
  createdAt DateTime @default(now())
}

model MagicLinkAttempt {
  id        String   @id @default(cuid())
  email     String
  createdAt DateTime @default(now())

  @@index([email, createdAt])
}

Key Security Features

  • JWT with HS256: Tokens are cryptographically signed and can’t be forged
  • Short Expiry: 15-minute window limits window of opportunity if email is compromised
  • Rate Limiting: Prevents abuse and brute-force attempts
  • Secure Cookies: Session tokens use httpOnly and secure flags
  • Email Normalization: Prevents case-sensitivity issues

SMS OTP (One-Time Password)

SMS OTP provides even higher assurance because it requires possession of the actual phone number, not just access to an email account. However, it’s more expensive and vulnerable to SIM swapping.

Production Implementation

import crypto from 'crypto';
import { Router, Request, Response } from 'express';
import twilio from 'twilio';
import { prisma } from '@/lib/prisma';
import { logger } from '@/lib/logger';

const TWILIO_ACCOUNT_SID = process.env.TWILIO_ACCOUNT_SID!;
const TWILIO_AUTH_TOKEN = process.env.TWILIO_AUTH_TOKEN!;
const TWILIO_PHONE = process.env.TWILIO_PHONE!;

const OTP_LENGTH = 6;
const OTP_EXPIRY = 10 * 60 * 1000; // 10 minutes
const MAX_ATTEMPTS = 3; // Failed verification attempts
const MAX_REQUESTS_PER_HOUR = 3; // Request rate limit

const twilioClient = twilio(TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN);

/**
 * Generate a cryptographically random OTP
 */
function generateOTP(): string {
  const maxValue = Math.pow(10, OTP_LENGTH) - 1;
  const randomValue = crypto.randomInt(0, maxValue + 1);
  return String(randomValue).padStart(OTP_LENGTH, '0');
}

/**
 * Check rate limit for OTP requests per phone number
 */
async function checkOTPRequestLimit(phone: string): Promise<boolean> {
  const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000);

  const recentRequests = await prisma.otpAttempt.count({
    where: {
      phone: normalizePhone(phone),
      createdAt: { gte: oneHourAgo },
      verified: false,
    },
  });

  return recentRequests < MAX_REQUESTS_PER_HOUR;
}

/**
 * Normalize phone number to E.164 format
 */
function normalizePhone(phone: string): string {
  // Remove all non-numeric characters
  const cleaned = phone.replace(/\D/g, '');
  
  // If it's a US number without country code, add it
  if (cleaned.length === 10) {
    return `+1${cleaned}`;
  }
  
  if (!cleaned.startsWith('+')) {
    return `+${cleaned}`;
  }
  
  return cleaned;
}

/**
 * Store OTP with hashed value in database
 * We hash the OTP so a database breach doesn't expose active tokens
 */
async function storeOTP(phone: string): Promise<string> {
  const normalizedPhone = normalizePhone(phone);
  const otp = generateOTP();
  const otpHash = crypto.createHash('sha256').update(otp).digest('hex');

  await prisma.otpAttempt.create({
    data: {
      phone: normalizedPhone,
      otpHash,
      expiresAt: new Date(Date.now() + OTP_EXPIRY),
      failedAttempts: 0,
      verified: false,
    },
  });

  return otp;
}

/**
 * Send OTP via SMS
 */
async function sendOTPSMS(
  phone: string,
  otp: string
): Promise<void> {
  try {
    await twilioClient.messages.create({
      body: `Your sign-in code is: ${otp}\nDo not share this code with anyone.`,
      from: TWILIO_PHONE,
      to: normalizePhone(phone),
    });

    logger.info('OTP sent successfully', { phone: normalizePhone(phone) });
  } catch (error) {
    logger.error('Failed to send OTP SMS', { phone: normalizePhone(phone), error });
    throw new Error('Failed to send SMS. Please try again.');
  }
}

/**
 * Request OTP endpoint
 */
export async function requestOTP(req: Request, res: Response) {
  const { phone } = req.body;

  if (!phone || typeof phone !== 'string') {
    return res.status(400).json({ error: 'Phone number is required' });
  }

  try {
    const normalizedPhone = normalizePhone(phone);

    // Check rate limit
    const withinLimit = await checkOTPRequestLimit(phone);
    if (!withinLimit) {
      return res.status(429).json({
        error: 'Too many requests. Please try again later.',
      });
    }

    // Store OTP
    const otp = await storeOTP(phone);

    // Send SMS
    await sendOTPSMS(phone, otp);

    // Return masked phone for UI feedback
    const maskedPhone = normalizedPhone.slice(0, -4) + '****';

    return res.status(200).json({
      message: 'Code sent to your phone',
      phone: maskedPhone,
    });
  } catch (error) {
    logger.error('Error requesting OTP', { error });
    return res.status(500).json({
      error: 'Failed to send code. Please try again.',
    });
  }
}

/**
 * Verify OTP endpoint
 */
export async function verifyOTP(req: Request, res: Response) {
  const { phone, otp } = req.body;

  if (!phone || !otp) {
    return res.status(400).json({
      error: 'Phone number and code are required',
    });
  }

  try {
    const normalizedPhone = normalizePhone(phone);
    const otpHash = crypto.createHash('sha256').update(otp).digest('hex');

    // Find the OTP record
    const otpRecord = await prisma.otpAttempt.findFirst({
      where: {
        phone: normalizedPhone,
        verified: false,
        expiresAt: { gt: new Date() },
      },
      orderBy: { createdAt: 'desc' },
    });

    if (!otpRecord) {
      return res.status(401).json({
        error: 'Code not found or expired. Please request a new one.',
      });
    }

    // Check if too many failed attempts
    if (otpRecord.failedAttempts >= MAX_ATTEMPTS) {
      return res.status(429).json({
        error: 'Too many failed attempts. Please request a new code.',
      });
    }

    // Verify OTP using constant-time comparison
    const isValid = crypto.timingSafeEqual(
      Buffer.from(otpHash),
      Buffer.from(otpRecord.otpHash)
    );

    if (!isValid) {
      // Increment failed attempts
      await prisma.otpAttempt.update({
        where: { id: otpRecord.id },
        data: {
          failedAttempts: otpRecord.failedAttempts + 1,
        },
      });

      return res.status(401).json({
        error: 'Invalid code. Please try again.',
      });
    }

    // Mark as verified
    await prisma.otpAttempt.update({
      where: { id: otpRecord.id },
      data: { verified: true },
    });

    // Find or create user
    let user = await prisma.user.findUnique({
      where: { phone: normalizedPhone },
    });

    if (!user) {
      user = await prisma.user.create({
        data: {
          phone: normalizedPhone,
          email: undefined,
        },
      });
    }

    // Create session
    const sessionToken = crypto.randomBytes(32).toString('hex');
    await prisma.session.create({
      data: {
        token: sessionToken,
        userId: user.id,
        expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
      },
    });

    res.cookie('sessionToken', sessionToken, {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'lax',
      maxAge: 30 * 24 * 60 * 60 * 1000,
      path: '/',
    });

    logger.info('User authenticated via OTP', { userId: user.id });

    return res.status(200).json({
      message: 'Authenticated successfully',
      user: { id: user.id, phone: user.phone },
    });
  } catch (error) {
    logger.error('Error verifying OTP', { error });
    return res.status(500).json({
      error: 'An error occurred. Please try again.',
    });
  }
}
import com.twilio.Twilio;
import com.twilio.rest.api.v2010.account.Message;
import com.twilio.type.PhoneNumber;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.security.*;
import java.time.*;
import java.util.*;

@RestController
@RequestMapping("/auth")
public class OtpController {

    private static final int OTP_LENGTH = 6;
    private static final int OTP_EXPIRY_MINUTES = 10;
    private static final int MAX_ATTEMPTS = 3;
    private static final int MAX_REQUESTS_PER_HOUR = 3;

    @Autowired private OtpAttemptRepository otpRepo;
    @Autowired private UserRepository userRepo;
    @Autowired private SessionRepository sessionRepo;
    @Value("${twilio.account-sid}") private String twilioSid;
    @Value("${twilio.auth-token}") private String twilioToken;
    @Value("${twilio.phone}") private String twilioPhone;

    private String generateOTP() {
        SecureRandom sr = new SecureRandom();
        int max = (int) Math.pow(10, OTP_LENGTH) - 1;
        return String.format("%0" + OTP_LENGTH + "d", sr.nextInt(max + 1));
    }

    private String normalizePhone(String phone) {
        String cleaned = phone.replaceAll("\\D", "");
        if (cleaned.length() == 10) return "+1" + cleaned;
        if (!cleaned.startsWith("+")) return "+" + cleaned;
        return cleaned;
    }

    private String hashOtp(String otp) throws NoSuchAlgorithmException {
        MessageDigest digest = MessageDigest.getInstance("SHA-256");
        byte[] hash = digest.digest(otp.getBytes(StandardCharsets.UTF_8));
        return HexFormat.of().formatHex(hash);
    }

    private boolean checkRateLimit(String phone) {
        Instant oneHourAgo = Instant.now().minusSeconds(3600);
        long count = otpRepo.countByPhoneAndVerifiedFalseAndCreatedAtAfter(
            normalizePhone(phone), oneHourAgo);
        return count < MAX_REQUESTS_PER_HOUR;
    }

    @PostMapping("/otp/request")
    public ResponseEntity<Map<String, String>> requestOTP(
            @RequestBody Map<String, String> body) throws Exception {
        String phone = body.get("phone");
        if (phone == null || phone.isBlank())
            return ResponseEntity.badRequest()
                .body(Map.of("error", "Phone number is required"));

        if (!checkRateLimit(phone))
            return ResponseEntity.status(429)
                .body(Map.of("error", "Too many requests. Please try again later."));

        String normalizedPhone = normalizePhone(phone);
        String otp = generateOTP();
        String otpHash = hashOtp(otp);

        otpRepo.save(OtpAttempt.builder()
            .phone(normalizedPhone).otpHash(otpHash)
            .expiresAt(Instant.now().plusSeconds(OTP_EXPIRY_MINUTES * 60))
            .failedAttempts(0).verified(false).build());

        Twilio.init(twilioSid, twilioToken);
        Message.creator(new PhoneNumber(normalizedPhone),
            new PhoneNumber(twilioPhone),
            "Your sign-in code is: " + otp + "\nDo not share this code.").create();

        String maskedPhone = normalizedPhone.substring(0, normalizedPhone.length() - 4) + "****";
        return ResponseEntity.ok(Map.of("message", "Code sent", "phone", maskedPhone));
    }

    @PostMapping("/otp/verify")
    public ResponseEntity<Map<String, Object>> verifyOTP(
            @RequestBody Map<String, String> body,
            jakarta.servlet.http.HttpServletResponse response) throws Exception {
        String phone = body.get("phone");
        String otp = body.get("otp");
        if (phone == null || otp == null)
            return ResponseEntity.badRequest()
                .body(Map.of("error", "Phone and code are required"));

        String normalizedPhone = normalizePhone(phone);
        String otpHash = hashOtp(otp);

        OtpAttempt record = otpRepo.findLatestValid(normalizedPhone, Instant.now())
            .orElse(null);
        if (record == null)
            return ResponseEntity.status(401)
                .body(Map.of("error", "Code not found or expired."));

        if (record.getFailedAttempts() >= MAX_ATTEMPTS)
            return ResponseEntity.status(429)
                .body(Map.of("error", "Too many failed attempts."));

        boolean isValid = MessageDigest.isEqual(
            otpHash.getBytes(), record.getOtpHash().getBytes());
        if (!isValid) {
            record.setFailedAttempts(record.getFailedAttempts() + 1);
            otpRepo.save(record);
            return ResponseEntity.status(401).body(Map.of("error", "Invalid code."));
        }

        record.setVerified(true);
        otpRepo.save(record);

        User user = userRepo.findByPhone(normalizedPhone)
            .orElseGet(() -> userRepo.save(User.builder().phone(normalizedPhone).build()));

        SecureRandom sr = new SecureRandom();
        byte[] tokenBytes = new byte[32];
        sr.nextBytes(tokenBytes);
        String sessionToken = HexFormat.of().formatHex(tokenBytes);
        sessionRepo.save(Session.builder().token(sessionToken).userId(user.getId())
            .expiresAt(Instant.now().plus(30, ChronoUnit.DAYS)).build());

        ResponseCookie cookie = ResponseCookie.from("sessionToken", sessionToken)
            .httpOnly(true).secure(true).sameSite("Lax")
            .maxAge(Duration.ofDays(30)).path("/").build();
        response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());

        return ResponseEntity.ok(Map.of("message", "Authenticated successfully",
            "user", Map.of("id", user.getId(), "phone", user.getPhone())));
    }
}
import hashlib
import secrets
import re
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, Response
from pydantic import BaseModel
from twilio.rest import Client as TwilioClient
from sqlalchemy.orm import Session

router = APIRouter()

OTP_LENGTH = 6
OTP_EXPIRY_MINUTES = 10
MAX_ATTEMPTS = 3
MAX_REQUESTS_PER_HOUR = 3

twilio_client = TwilioClient(settings.TWILIO_ACCOUNT_SID, settings.TWILIO_AUTH_TOKEN)


def generate_otp() -> str:
    max_value = 10 ** OTP_LENGTH
    return str(secrets.randbelow(max_value)).zfill(OTP_LENGTH)


def normalize_phone(phone: str) -> str:
    cleaned = re.sub(r"\D", "", phone)
    if len(cleaned) == 10:
        return f"+1{cleaned}"
    if not cleaned.startswith("+"):
        return f"+{cleaned}"
    return cleaned


def hash_otp(otp: str) -> str:
    return hashlib.sha256(otp.encode()).hexdigest()


def check_rate_limit(db: Session, phone: str) -> bool:
    one_hour_ago = datetime.now(timezone.utc) - timedelta(hours=1)
    count = (db.query(OtpAttempt)
        .filter(OtpAttempt.phone == normalize_phone(phone),
                OtpAttempt.verified == False,
                OtpAttempt.created_at >= one_hour_ago)
        .count())
    return count < MAX_REQUESTS_PER_HOUR


class OtpRequest(BaseModel):
    phone: str

class OtpVerify(BaseModel):
    phone: str
    otp: str


@router.post("/auth/otp/request")
async def request_otp(body: OtpRequest, db: Session = Depends(get_db)):
    if not check_rate_limit(db, body.phone):
        raise HTTPException(status_code=429,
            detail="Too many requests. Please try again later.")

    normalized_phone = normalize_phone(body.phone)
    otp = generate_otp()
    otp_hash = hash_otp(otp)

    db.add(OtpAttempt(
        phone=normalized_phone, otp_hash=otp_hash,
        expires_at=datetime.now(timezone.utc) + timedelta(minutes=OTP_EXPIRY_MINUTES),
        failed_attempts=0, verified=False,
    ))
    db.commit()

    twilio_client.messages.create(
        body=f"Your sign-in code is: {otp}\nDo not share this code.",
        from_=settings.TWILIO_PHONE,
        to=normalized_phone,
    )

    masked_phone = normalized_phone[:-4] + "****"
    return {"message": "Code sent to your phone", "phone": masked_phone}


@router.post("/auth/otp/verify")
async def verify_otp(
    body: OtpVerify,
    response: Response,
    db: Session = Depends(get_db),
):
    normalized_phone = normalize_phone(body.phone)
    otp_hash = hash_otp(body.otp)

    record = (db.query(OtpAttempt)
        .filter(OtpAttempt.phone == normalized_phone,
                OtpAttempt.verified == False,
                OtpAttempt.expires_at > datetime.now(timezone.utc))
        .order_by(OtpAttempt.created_at.desc())
        .first())

    if not record:
        raise HTTPException(status_code=401,
            detail="Code not found or expired. Please request a new one.")

    if record.failed_attempts >= MAX_ATTEMPTS:
        raise HTTPException(status_code=429,
            detail="Too many failed attempts. Please request a new code.")

    is_valid = secrets.compare_digest(otp_hash, record.otp_hash)
    if not is_valid:
        record.failed_attempts += 1
        db.commit()
        raise HTTPException(status_code=401, detail="Invalid code. Please try again.")

    record.verified = True
    db.commit()

    user = db.query(User).filter_by(phone=normalized_phone).first()
    if not user:
        user = User(phone=normalized_phone)
        db.add(user)
        db.commit()

    session_token = secrets.token_hex(32)
    db.add(Session(
        token=session_token, user_id=user.id,
        expires_at=datetime.now(timezone.utc) + timedelta(days=30),
    ))
    db.commit()

    response.set_cookie("sessionToken", session_token,
        httponly=True, secure=True, samesite="lax",
        max_age=30 * 24 * 60 * 60, path="/")

    return {"message": "Authenticated successfully",
            "user": {"id": user.id, "phone": user.phone}}
using System.Security.Cryptography;
using Twilio;
using Twilio.Rest.Api.V2010.Account;
using Twilio.Types;

[ApiController]
[Route("auth")]
public class OtpController : ControllerBase
{
    private const int OtpLength = 6;
    private const int OtpExpiryMinutes = 10;
    private const int MaxAttempts = 3;
    private const int MaxRequestsPerHour = 3;

    private readonly AppDbContext _db;
    private readonly IConfiguration _config;

    public OtpController(AppDbContext db, IConfiguration config)
    { _db = db; _config = config; }

    private static string GenerateOtp()
    {
        int max = (int)Math.Pow(10, OtpLength);
        return RandomNumberGenerator.GetInt32(max).ToString().PadLeft(OtpLength, '0');
    }

    private static string NormalizePhone(string phone)
    {
        var cleaned = new string(phone.Where(char.IsDigit).ToArray());
        return cleaned.Length == 10 ? $"+1{cleaned}" : $"+{cleaned}";
    }

    private static string HashOtp(string otp) =>
        Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(otp))).ToLower();

    private async Task<bool> CheckRateLimitAsync(string phone)
    {
        var oneHourAgo = DateTimeOffset.UtcNow.AddHours(-1);
        var count = await _db.OtpAttempts.CountAsync(a =>
            a.Phone == NormalizePhone(phone) &&
            !a.Verified && a.CreatedAt >= oneHourAgo);
        return count < MaxRequestsPerHour;
    }

    [HttpPost("otp/request")]
    public async Task<IActionResult> RequestOtp([FromBody] OtpRequest body)
    {
        if (!await CheckRateLimitAsync(body.Phone))
            return StatusCode(429, new { error = "Too many requests. Please try again later." });

        var normalizedPhone = NormalizePhone(body.Phone);
        var otp = GenerateOtp();
        var otpHash = HashOtp(otp);

        _db.OtpAttempts.Add(new OtpAttempt {
            Phone = normalizedPhone, OtpHash = otpHash,
            ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(OtpExpiryMinutes),
            FailedAttempts = 0, Verified = false,
        });
        await _db.SaveChangesAsync();

        TwilioClient.Init(_config["Twilio:AccountSid"], _config["Twilio:AuthToken"]);
        await MessageResource.CreateAsync(
            body: $"Your sign-in code is: {otp}\nDo not share this code.",
            from: new PhoneNumber(_config["Twilio:Phone"]),
            to: new PhoneNumber(normalizedPhone));

        var maskedPhone = normalizedPhone[..^4] + "****";
        return Ok(new { message = "Code sent to your phone", phone = maskedPhone });
    }

    [HttpPost("otp/verify")]
    public async Task<IActionResult> VerifyOtp(
        [FromBody] OtpVerifyRequest body)
    {
        var normalizedPhone = NormalizePhone(body.Phone);
        var otpHash = HashOtp(body.Otp);

        var record = await _db.OtpAttempts
            .Where(a => a.Phone == normalizedPhone && !a.Verified &&
                        a.ExpiresAt > DateTimeOffset.UtcNow)
            .OrderByDescending(a => a.CreatedAt)
            .FirstOrDefaultAsync();

        if (record == null)
            return Unauthorized(new { error = "Code not found or expired." });

        if (record.FailedAttempts >= MaxAttempts)
            return StatusCode(429, new { error = "Too many failed attempts." });

        var isValid = CryptographicOperations.FixedTimeEquals(
            Encoding.UTF8.GetBytes(otpHash),
            Encoding.UTF8.GetBytes(record.OtpHash));

        if (!isValid)
        {
            record.FailedAttempts++;
            await _db.SaveChangesAsync();
            return Unauthorized(new { error = "Invalid code. Please try again." });
        }

        record.Verified = true;
        await _db.SaveChangesAsync();

        var user = await _db.Users.FirstOrDefaultAsync(u => u.Phone == normalizedPhone)
            ?? _db.Users.Add(new User { Phone = normalizedPhone }).Entity;
        await _db.SaveChangesAsync();

        var sessionToken = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLower();
        _db.Sessions.Add(new Session {
            Token = sessionToken, UserId = user.Id,
            ExpiresAt = DateTimeOffset.UtcNow.AddDays(30),
        });
        await _db.SaveChangesAsync();

        Response.Cookies.Append("sessionToken", sessionToken, new CookieOptions {
            HttpOnly = true, Secure = true, SameSite = SameSiteMode.Lax,
            MaxAge = TimeSpan.FromDays(30), Path = "/",
        });

        return Ok(new { message = "Authenticated successfully",
            user = new { user.Id, user.Phone } });
    }
}

Database Schema Addition

model OtpAttempt {
  id              String   @id @default(cuid())
  phone           String
  otpHash         String
  verified        Boolean  @default(false)
  failedAttempts  Int      @default(0)
  expiresAt       DateTime
  createdAt       DateTime @default(now())

  @@index([phone, verified, expiresAt])
}

WebAuthn/FIDO2: The Gold Standard

WebAuthn (Web Authentication) is a W3C standard that leverages cryptographic keys stored on your device (passkeys, security keys, biometric authentication). It’s the most secure passwordless approach.

How WebAuthn Works

  1. Registration: User provides their identity and credential (biometric or security key)
  2. Challenge: Server generates a challenge
  3. Attestation: Device signs the challenge with its private key and returns the public key
  4. Storage: Server stores the public key associated with the user
  5. Authentication: For login, server sends a new challenge
  6. Assertion: Device signs it with the private key
  7. Verification: Server verifies the signature using the stored public key

Production Implementation with @simplewebauthn/server

import {
  generateRegistrationOptions,
  verifyRegistrationResponse,
  generateAuthenticationOptions,
  verifyAuthenticationResponse,
  RegistrationResponseJSON,
  AuthenticationResponseJSON,
} from '@simplewebauthn/server';
import {
  isoBase64URL,
  isoUint8Array,
} from '@simplewebauthn/server/helpers/iso';
import { Router, Request, Response } from 'express';
import crypto from 'crypto';
import { prisma } from '@/lib/prisma';
import { logger } from '@/lib/logger';

const RP_ID = process.env.RP_ID || 'localhost';
const RP_NAME = process.env.RP_NAME || 'My Application';
const ORIGIN = process.env.ORIGIN || 'http://localhost:3000';

/**
 * Start WebAuthn registration
 * Returns options for the authenticator
 */
export async function startWebAuthnRegistration(
  req: Request,
  res: Response
) {
  const { email } = req.body;

  if (!email) {
    return res.status(400).json({ error: 'Email is required' });
  }

  try {
    // Check if user exists
    let user = await prisma.user.findUnique({
      where: { email },
      include: { webAuthnCredentials: true },
    });

    if (!user) {
      user = await prisma.user.create({
        data: {
          email,
          name: email.split('@')[0],
        },
      });
    }

    // Generate registration options
    const options = await generateRegistrationOptions({
      rpID: RP_ID,
      rpName: RP_NAME,
      userID: isoUint8Array.fromUTF8String(user.id),
      userName: email,
      userDisplayName: user.name || email,
      attestationType: 'direct',
      authenticatorSelection: {
        authenticatorAttachment: 'platform',
        residentKey: 'preferred',
        userVerification: 'preferred',
      },
      supportedAlgorithmIDs: [-7, -257], // ES256, RS256
    });

    // Store challenge in database for later verification
    await prisma.webAuthnChallenge.create({
      data: {
        challenge: options.challenge,
        userId: user.id,
        type: 'registration',
        expiresAt: new Date(Date.now() + 15 * 60 * 1000), // 15 minutes
      },
    });

    return res.status(200).json(options);
  } catch (error) {
    logger.error('Error starting WebAuthn registration', { error });
    return res.status(500).json({
      error: 'Failed to start registration',
    });
  }
}

/**
 * Complete WebAuthn registration
 * Verify the attestation and store the credential
 */
export async function completeWebAuthnRegistration(
  req: Request,
  res: Response
) {
  const { email, credential } = req.body;

  if (!email || !credential) {
    return res.status(400).json({
      error: 'Email and credential are required',
    });
  }

  try {
    const user = await prisma.user.findUnique({
      where: { email },
    });

    if (!user) {
      return res.status(404).json({ error: 'User not found' });
    }

    // Find the challenge
    const challenge = await prisma.webAuthnChallenge.findFirst({
      where: {
        userId: user.id,
        type: 'registration',
        expiresAt: { gt: new Date() },
      },
      orderBy: { createdAt: 'desc' },
    });

    if (!challenge) {
      return res.status(400).json({
        error: 'Challenge expired. Please start registration again.',
      });
    }

    // Verify the registration response
    let verification;
    try {
      verification = await verifyRegistrationResponse({
        response: credential as RegistrationResponseJSON,
        expectedChallenge: challenge.challenge,
        expectedOrigin: ORIGIN,
        expectedRPID: RP_ID,
      });
    } catch (error) {
      logger.warn('WebAuthn registration verification failed', {
        email,
        error,
      });
      return res.status(400).json({
        error: 'Registration verification failed',
      });
    }

    if (!verification.verified || !verification.registrationInfo) {
      return res.status(400).json({
        error: 'Registration could not be verified',
      });
    }

    const { registrationInfo } = verification;

    // Store the credential
    await prisma.webAuthnCredential.create({
      data: {
        userId: user.id,
        credentialID: isoBase64URL.fromBuffer(registrationInfo.credentialID),
        publicKeyBytes: isoBase64URL.fromBuffer(registrationInfo.credentialPublicKey),
        signCount: registrationInfo.signCount,
        transports: credential.response.transports || [],
        name: credential.response.clientExtensionResults?.credProps?.rk
          ? 'Platform Authenticator'
          : 'Security Key',
      },
    });

    // Mark challenge as used
    await prisma.webAuthnChallenge.update({
      where: { id: challenge.id },
      data: { used: true },
    });

    // Create session
    const sessionToken = crypto.randomBytes(32).toString('hex');
    await prisma.session.create({
      data: {
        token: sessionToken,
        userId: user.id,
        expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
      },
    });

    res.cookie('sessionToken', sessionToken, {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'lax',
      maxAge: 30 * 24 * 60 * 60 * 1000,
      path: '/',
    });

    logger.info('WebAuthn credential registered', {
      userId: user.id,
      credentialID: registrationInfo.credentialID.byteLength,
    });

    return res.status(200).json({
      message: 'Passkey registered successfully',
      user: { id: user.id, email: user.email },
    });
  } catch (error) {
    logger.error('Error completing WebAuthn registration', { error });
    return res.status(500).json({
      error: 'Failed to complete registration',
    });
  }
}

/**
 * Start WebAuthn authentication
 */
export async function startWebAuthnAuthentication(
  req: Request,
  res: Response
) {
  const { email } = req.body;

  if (!email) {
    return res.status(400).json({ error: 'Email is required' });
  }

  try {
    const user = await prisma.user.findUnique({
      where: { email },
      include: { webAuthnCredentials: true },
    });

    if (!user || user.webAuthnCredentials.length === 0) {
      return res.status(404).json({
        error: 'User has no registered passkeys',
      });
    }

    // Generate authentication options
    const options = await generateAuthenticationOptions({
      rpID: RP_ID,
      allowCredentials: user.webAuthnCredentials.map((cred) => ({
        id: isoBase64URL.toBuffer(cred.credentialID),
        type: 'public-key',
        transports: cred.transports as AuthenticatorTransport[],
      })),
      userVerification: 'preferred',
    });

    // Store challenge
    await prisma.webAuthnChallenge.create({
      data: {
        challenge: options.challenge,
        userId: user.id,
        type: 'authentication',
        expiresAt: new Date(Date.now() + 10 * 60 * 1000), // 10 minutes
      },
    });

    return res.status(200).json(options);
  } catch (error) {
    logger.error('Error starting WebAuthn authentication', { error });
    return res.status(500).json({
      error: 'Failed to start authentication',
    });
  }
}

/**
 * Complete WebAuthn authentication
 */
export async function completeWebAuthnAuthentication(
  req: Request,
  res: Response
) {
  const { email, assertion } = req.body;

  if (!email || !assertion) {
    return res.status(400).json({
      error: 'Email and assertion are required',
    });
  }

  try {
    const user = await prisma.user.findUnique({
      where: { email },
      include: { webAuthnCredentials: true },
    });

    if (!user) {
      return res.status(404).json({ error: 'User not found' });
    }

    // Find the challenge
    const challenge = await prisma.webAuthnChallenge.findFirst({
      where: {
        userId: user.id,
        type: 'authentication',
        expiresAt: { gt: new Date() },
      },
      orderBy: { createdAt: 'desc' },
    });

    if (!challenge) {
      return res.status(400).json({
        error: 'Challenge expired. Please start authentication again.',
      });
    }

    // Find the credential that was used
    const credentialID = assertion.id;
    const credential = user.webAuthnCredentials.find(
      (cred) => cred.credentialID === credentialID
    );

    if (!credential) {
      return res.status(404).json({
        error: 'Credential not found',
      });
    }

    // Verify the authentication response
    let verification;
    try {
      verification = await verifyAuthenticationResponse({
        response: assertion as AuthenticationResponseJSON,
        expectedChallenge: challenge.challenge,
        expectedOrigin: ORIGIN,
        expectedRPID: RP_ID,
        credential: {
          id: isoBase64URL.toBuffer(credential.credentialID),
          publicKey: isoBase64URL.toBuffer(credential.publicKeyBytes),
          signCount: credential.signCount,
          transports: credential.transports as AuthenticatorTransport[],
        },
      });
    } catch (error) {
      logger.warn('WebAuthn authentication verification failed', {
        email,
        error,
      });
      return res.status(401).json({
        error: 'Authentication verification failed',
      });
    }

    if (!verification.verified) {
      return res.status(401).json({
        error: 'Authentication could not be verified',
      });
    }

    // Check for cloned authenticator (sign count should increase)
    if (verification.authenticationInfo.signCount <= credential.signCount) {
      logger.warn('Potential cloned authenticator detected', {
        userId: user.id,
        credentialID,
      });
      // You might want to require re-authentication here
    }

    // Update sign count
    await prisma.webAuthnCredential.update({
      where: { id: credential.id },
      data: { signCount: verification.authenticationInfo.signCount },
    });

    // Mark challenge as used
    await prisma.webAuthnChallenge.update({
      where: { id: challenge.id },
      data: { used: true },
    });

    // Create session
    const sessionToken = crypto.randomBytes(32).toString('hex');
    await prisma.session.create({
      data: {
        token: sessionToken,
        userId: user.id,
        expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
      },
    });

    res.cookie('sessionToken', sessionToken, {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'lax',
      maxAge: 30 * 24 * 60 * 60 * 1000,
      path: '/',
    });

    logger.info('User authenticated via WebAuthn', {
      userId: user.id,
      credentialID,
    });

    return res.status(200).json({
      message: 'Authenticated successfully',
      user: { id: user.id, email: user.email },
    });
  } catch (error) {
    logger.error('Error completing WebAuthn authentication', { error });
    return res.status(500).json({
      error: 'Failed to complete authentication',
    });
  }
}
// Spring Boot with webauthn4j
import com.webauthn4j.WebAuthnManager;
import com.webauthn4j.credential.CoreRegistrationObject;
import com.webauthn4j.data.*;
import com.webauthn4j.data.client.Origin;
import com.webauthn4j.server.ServerProperty;
import org.springframework.web.bind.annotation.*;
import java.security.SecureRandom;
import java.util.*;

@RestController
@RequestMapping("/auth/webauthn")
public class WebAuthnController {

    private static final String RP_ID = System.getenv("RP_ID");
    private static final String RP_NAME = System.getenv("RP_NAME");
    private static final String ORIGIN = System.getenv("ORIGIN");

    @Autowired private UserRepository userRepo;
    @Autowired private WebAuthnCredentialRepository credRepo;
    @Autowired private WebAuthnChallengeRepository challengeRepo;
    @Autowired private SessionRepository sessionRepo;

    private final WebAuthnManager webAuthnManager = WebAuthnManager.createNonStrictWebAuthnManager();

    @PostMapping("/registration/start")
    public ResponseEntity<Map<String, Object>> startRegistration(
            @RequestBody Map<String, String> body) {
        String email = body.get("email");
        if (email == null) return ResponseEntity.badRequest()
            .body(Map.of("error", "Email is required"));

        User user = userRepo.findByEmail(email).orElseGet(() ->
            userRepo.save(User.builder().email(email)
                .name(email.split("@")[0]).build()));

        byte[] challengeBytes = new byte[32];
        new SecureRandom().nextBytes(challengeBytes);
        String challenge = Base64.getUrlEncoder().withoutPadding()
            .encodeToString(challengeBytes);

        challengeRepo.save(WebAuthnChallenge.builder()
            .challenge(challenge).userId(user.getId())
            .type("registration")
            .expiresAt(Instant.now().plusSeconds(900))
            .build());

        Map<String, Object> options = new LinkedHashMap<>();
        options.put("challenge", challenge);
        options.put("rp", Map.of("id", RP_ID, "name", RP_NAME));
        options.put("user", Map.of("id",
            Base64.getUrlEncoder().withoutPadding()
                .encodeToString(user.getId().getBytes()),
            "name", email, "displayName", user.getName()));
        options.put("pubKeyCredParams", List.of(
            Map.of("type", "public-key", "alg", -7),   // ES256
            Map.of("type", "public-key", "alg", -257))); // RS256
        options.put("authenticatorSelection", Map.of(
            "authenticatorAttachment", "platform",
            "residentKey", "preferred",
            "userVerification", "preferred"));
        return ResponseEntity.ok(options);
    }

    @PostMapping("/registration/complete")
    public ResponseEntity<Map<String, Object>> completeRegistration(
            @RequestBody Map<String, Object> body,
            jakarta.servlet.http.HttpServletResponse response) {
        String email = (String) body.get("email");
        // Deserialize credential and verify with webauthn4j
        // ... (full verification logic)
        // Store credential, create session, set cookie
        return ResponseEntity.ok(Map.of("message", "Passkey registered successfully"));
    }

    @PostMapping("/authentication/start")
    public ResponseEntity<Map<String, Object>> startAuthentication(
            @RequestBody Map<String, String> body) {
        String email = body.get("email");
        User user = userRepo.findByEmail(email)
            .orElseThrow(() -> new ResponseStatusException(
                HttpStatus.NOT_FOUND, "User not found"));

        List<WebAuthnCredential> creds = credRepo.findByUserId(user.getId());
        if (creds.isEmpty()) return ResponseEntity.status(404)
            .body(Map.of("error", "User has no registered passkeys"));

        byte[] challengeBytes = new byte[32];
        new SecureRandom().nextBytes(challengeBytes);
        String challenge = Base64.getUrlEncoder().withoutPadding()
            .encodeToString(challengeBytes);

        challengeRepo.save(WebAuthnChallenge.builder()
            .challenge(challenge).userId(user.getId())
            .type("authentication")
            .expiresAt(Instant.now().plusSeconds(600))
            .build());

        List<Map<String, Object>> allowCredentials = creds.stream()
            .map(c -> Map.<String, Object>of(
                "id", c.getCredentialId(),
                "type", "public-key",
                "transports", c.getTransports()))
            .toList();

        return ResponseEntity.ok(Map.of(
            "challenge", challenge,
            "rpId", RP_ID,
            "allowCredentials", allowCredentials,
            "userVerification", "preferred"));
    }

    @PostMapping("/authentication/complete")
    public ResponseEntity<Map<String, Object>> completeAuthentication(
            @RequestBody Map<String, Object> body,
            jakarta.servlet.http.HttpServletResponse response) {
        String email = (String) body.get("email");
        // Verify assertion with webauthn4j, update sign count, create session
        // ... (full verification logic)
        return ResponseEntity.ok(Map.of("message", "Authenticated successfully"));
    }
}
from fastapi import APIRouter, Depends, HTTPException, Response
from pydantic import BaseModel
from py_webauthn import (
    generate_registration_options, verify_registration_response,
    generate_authentication_options, verify_authentication_response,
    options_to_json,
)
from py_webauthn.helpers.structs import (
    AuthenticatorSelectionCriteria, ResidentKeyRequirement,
    UserVerificationRequirement, AttestationConveyancePreference,
    AuthenticatorAttachment,
)
import secrets, base64
from sqlalchemy.orm import Session

router = APIRouter()

RP_ID = settings.RP_ID or "localhost"
RP_NAME = settings.RP_NAME or "My Application"
ORIGIN = settings.ORIGIN or "http://localhost:3000"


@router.post("/auth/webauthn/registration/start")
async def start_registration(
    body: dict,
    db: Session = Depends(get_db),
):
    email = body.get("email")
    if not email:
        raise HTTPException(status_code=400, detail="Email is required")

    user = db.query(User).filter_by(email=email).first()
    if not user:
        user = User(email=email, name=email.split("@")[0])
        db.add(user)
        db.commit()

    options = generate_registration_options(
        rp_id=RP_ID, rp_name=RP_NAME,
        user_id=user.id.encode(), user_name=email,
        user_display_name=user.name or email,
        attestation=AttestationConveyancePreference.DIRECT,
        authenticator_selection=AuthenticatorSelectionCriteria(
            authenticator_attachment=AuthenticatorAttachment.PLATFORM,
            resident_key=ResidentKeyRequirement.PREFERRED,
            user_verification=UserVerificationRequirement.PREFERRED,
        ),
    )

    db.add(WebAuthnChallenge(
        challenge=options.challenge,
        user_id=user.id, type="registration",
        expires_at=datetime.now(timezone.utc) + timedelta(minutes=15),
    ))
    db.commit()
    return options_to_json(options)


@router.post("/auth/webauthn/registration/complete")
async def complete_registration(
    body: dict,
    response: Response,
    db: Session = Depends(get_db),
):
    email = body.get("email")
    credential = body.get("credential")

    user = db.query(User).filter_by(email=email).first()
    if not user:
        raise HTTPException(status_code=404, detail="User not found")

    challenge_record = (db.query(WebAuthnChallenge)
        .filter_by(user_id=user.id, type="registration")
        .filter(WebAuthnChallenge.expires_at > datetime.now(timezone.utc))
        .order_by(WebAuthnChallenge.created_at.desc())
        .first())
    if not challenge_record:
        raise HTTPException(status_code=400,
            detail="Challenge expired. Please start registration again.")

    verification = verify_registration_response(
        credential=credential,
        expected_challenge=challenge_record.challenge,
        expected_rp_id=RP_ID, expected_origin=ORIGIN,
    )

    db.add(WebAuthnCredential(
        user_id=user.id,
        credential_id=base64.urlsafe_b64encode(
            verification.credential_id).rstrip(b'=').decode(),
        public_key_bytes=base64.urlsafe_b64encode(
            verification.credential_public_key).rstrip(b'=').decode(),
        sign_count=verification.sign_count,
        transports=credential.get("response", {}).get("transports", []),
        name="Platform Authenticator",
    ))

    challenge_record.used = True
    db.commit()

    session_token = secrets.token_hex(32)
    db.add(Session(token=session_token, user_id=user.id,
        expires_at=datetime.now(timezone.utc) + timedelta(days=30)))
    db.commit()

    response.set_cookie("sessionToken", session_token,
        httponly=True, secure=True, samesite="lax",
        max_age=30 * 24 * 60 * 60, path="/")
    return {"message": "Passkey registered successfully",
            "user": {"id": user.id, "email": user.email}}


@router.post("/auth/webauthn/authentication/start")
async def start_authentication(body: dict, db: Session = Depends(get_db)):
    email = body.get("email")
    user = db.query(User).filter_by(email=email).first()
    if not user:
        raise HTTPException(status_code=404, detail="User not found")

    creds = db.query(WebAuthnCredential).filter_by(user_id=user.id).all()
    if not creds:
        raise HTTPException(status_code=404,
            detail="User has no registered passkeys")

    options = generate_authentication_options(
        rp_id=RP_ID,
        allow_credentials=[{
            "id": base64.urlsafe_b64decode(c.credential_id + "=="),
            "type": "public-key", "transports": c.transports,
        } for c in creds],
        user_verification=UserVerificationRequirement.PREFERRED,
    )

    db.add(WebAuthnChallenge(challenge=options.challenge,
        user_id=user.id, type="authentication",
        expires_at=datetime.now(timezone.utc) + timedelta(minutes=10)))
    db.commit()
    return options_to_json(options)


@router.post("/auth/webauthn/authentication/complete")
async def complete_authentication(
    body: dict, response: Response,
    db: Session = Depends(get_db),
):
    email = body.get("email")
    assertion = body.get("assertion")

    user = db.query(User).filter_by(email=email).first()
    if not user:
        raise HTTPException(status_code=404, detail="User not found")

    challenge_record = (db.query(WebAuthnChallenge)
        .filter_by(user_id=user.id, type="authentication")
        .filter(WebAuthnChallenge.expires_at > datetime.now(timezone.utc))
        .order_by(WebAuthnChallenge.created_at.desc())
        .first())
    if not challenge_record:
        raise HTTPException(status_code=400,
            detail="Challenge expired. Please start authentication again.")

    credential_id = assertion.get("id")
    cred = next((c for c in user.webauthn_credentials
        if c.credential_id == credential_id), None)
    if not cred:
        raise HTTPException(status_code=404, detail="Credential not found")

    verification = verify_authentication_response(
        credential=assertion,
        expected_challenge=challenge_record.challenge,
        expected_rp_id=RP_ID, expected_origin=ORIGIN,
        credential_public_key=base64.urlsafe_b64decode(cred.public_key_bytes + "=="),
        credential_current_sign_count=cred.sign_count,
    )

    if verification.new_sign_count <= cred.sign_count:
        logger.warning(f"Potential cloned authenticator: user={user.id}")

    cred.sign_count = verification.new_sign_count
    challenge_record.used = True
    db.commit()

    session_token = secrets.token_hex(32)
    db.add(Session(token=session_token, user_id=user.id,
        expires_at=datetime.now(timezone.utc) + timedelta(days=30)))
    db.commit()

    response.set_cookie("sessionToken", session_token,
        httponly=True, secure=True, samesite="lax",
        max_age=30 * 24 * 60 * 60, path="/")
    return {"message": "Authenticated successfully",
            "user": {"id": user.id, "email": user.email}}
// ASP.NET Core with Fido2NetLib
using Fido2NetLib;
using Fido2NetLib.Objects;
using System.Security.Cryptography;

[ApiController]
[Route("auth/webauthn")]
public class WebAuthnController : ControllerBase
{
    private readonly IFido2 _fido2;
    private readonly AppDbContext _db;
    private readonly IConfiguration _config;

    public WebAuthnController(IFido2 fido2, AppDbContext db, IConfiguration config)
    { _fido2 = fido2; _db = db; _config = config; }

    [HttpPost("registration/start")]
    public async Task<IActionResult> StartRegistration([FromBody] EmailRequest body)
    {
        var user = await _db.Users.FirstOrDefaultAsync(u => u.Email == body.Email)
            ?? _db.Users.Add(new User {
                Email = body.Email, Name = body.Email.Split('@')[0]
            }).Entity;
        await _db.SaveChangesAsync();

        var existingCreds = await _db.WebAuthnCredentials
            .Where(c => c.UserId == user.Id)
            .Select(c => new PublicKeyCredentialDescriptor(
                Convert.FromBase64String(c.CredentialId)))
            .ToListAsync();

        var fido2User = new Fido2User {
            Id = System.Text.Encoding.UTF8.GetBytes(user.Id),
            Name = body.Email, DisplayName = user.Name ?? body.Email,
        };

        var options = _fido2.RequestNewCredential(fido2User, existingCreds,
            new AuthenticatorSelection {
                AuthenticatorAttachment = AuthenticatorAttachment.Platform,
                ResidentKey = ResidentKeyRequirement.Preferred,
                UserVerification = UserVerificationRequirement.Preferred,
            }, AttestationConveyancePreference.Direct);

        _db.WebAuthnChallenges.Add(new WebAuthnChallenge {
            Challenge = Convert.ToBase64String(options.Challenge),
            UserId = user.Id, Type = "registration",
            ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(15),
        });
        await _db.SaveChangesAsync();
        return Ok(options);
    }

    [HttpPost("registration/complete")]
    public async Task<IActionResult> CompleteRegistration(
        [FromBody] RegistrationCompleteRequest body)
    {
        var user = await _db.Users.FirstOrDefaultAsync(u => u.Email == body.Email);
        if (user == null) return NotFound(new { error = "User not found" });

        var challenge = await _db.WebAuthnChallenges
            .Where(c => c.UserId == user.Id && c.Type == "registration" &&
                        c.ExpiresAt > DateTimeOffset.UtcNow && !c.Used)
            .OrderByDescending(c => c.CreatedAt).FirstOrDefaultAsync();
        if (challenge == null)
            return BadRequest(new { error = "Challenge expired." });

        var options = new CredentialCreateOptions {
            Challenge = Convert.FromBase64String(challenge.Challenge) };

        IsCredentialIdUniqueToUserAsyncDelegate callback = async (args, ct) =>
            !await _db.WebAuthnCredentials.AnyAsync(c =>
                c.CredentialId == Convert.ToBase64String(args.CredentialId));

        var result = await _fido2.MakeNewCredentialAsync(
            body.Credential, options, callback);

        _db.WebAuthnCredentials.Add(new WebAuthnCredential {
            UserId = user.Id,
            CredentialId = Convert.ToBase64String(result.Result!.CredentialId),
            PublicKeyBytes = Convert.ToBase64String(result.Result.PublicKey),
            SignCount = (int)result.Result.SignCount,
            Transports = body.Transports ?? new List<string>(),
            Name = "Platform Authenticator",
        });
        challenge.Used = true;
        await _db.SaveChangesAsync();

        var sessionToken = CreateSession(user.Id);
        SetSessionCookie(sessionToken);
        return Ok(new { message = "Passkey registered successfully",
            user = new { user.Id, user.Email } });
    }

    [HttpPost("authentication/start")]
    public async Task<IActionResult> StartAuthentication([FromBody] EmailRequest body)
    {
        var user = await _db.Users.Include(u => u.WebAuthnCredentials)
            .FirstOrDefaultAsync(u => u.Email == body.Email);
        if (user == null || !user.WebAuthnCredentials.Any())
            return NotFound(new { error = "User has no registered passkeys" });

        var allowedCreds = user.WebAuthnCredentials.Select(c =>
            new PublicKeyCredentialDescriptor(
                Convert.FromBase64String(c.CredentialId))).ToList();

        var options = _fido2.GetAssertionOptions(allowedCreds,
            UserVerificationRequirement.Preferred);

        _db.WebAuthnChallenges.Add(new WebAuthnChallenge {
            Challenge = Convert.ToBase64String(options.Challenge),
            UserId = user.Id, Type = "authentication",
            ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(10),
        });
        await _db.SaveChangesAsync();
        return Ok(options);
    }

    [HttpPost("authentication/complete")]
    public async Task<IActionResult> CompleteAuthentication(
        [FromBody] AuthenticationCompleteRequest body)
    {
        var user = await _db.Users.Include(u => u.WebAuthnCredentials)
            .FirstOrDefaultAsync(u => u.Email == body.Email);
        if (user == null) return NotFound(new { error = "User not found" });

        var challenge = await _db.WebAuthnChallenges
            .Where(c => c.UserId == user.Id && c.Type == "authentication" &&
                        c.ExpiresAt > DateTimeOffset.UtcNow && !c.Used)
            .OrderByDescending(c => c.CreatedAt).FirstOrDefaultAsync();
        if (challenge == null)
            return BadRequest(new { error = "Challenge expired." });

        var credentialId = body.Assertion.Id;
        var cred = user.WebAuthnCredentials.FirstOrDefault(c =>
            c.CredentialId == credentialId);
        if (cred == null) return NotFound(new { error = "Credential not found" });

        var options = new AssertionOptions {
            Challenge = Convert.FromBase64String(challenge.Challenge) };

        IsUserHandleOwnerOfCredentialIdAsync callback = async (args, ct) =>
            await _db.WebAuthnCredentials.AnyAsync(c =>
                c.CredentialId == Convert.ToBase64String(args.CredentialId) &&
                c.UserId == user.Id);

        var result = await _fido2.MakeAssertionAsync(
            body.Assertion, options,
            Convert.FromBase64String(cred.PublicKeyBytes),
            (uint)cred.SignCount, callback);

        if (result.SignCount <= cred.SignCount)
            _logger.LogWarning("Potential cloned authenticator: userId={UserId}", user.Id);

        cred.SignCount = (int)result.SignCount;
        challenge.Used = true;
        await _db.SaveChangesAsync();

        var sessionToken = CreateSession(user.Id);
        SetSessionCookie(sessionToken);
        return Ok(new { message = "Authenticated successfully",
            user = new { user.Id, user.Email } });
    }

    private string CreateSession(string userId)
    {
        var token = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLower();
        _db.Sessions.Add(new Session {
            Token = token, UserId = userId,
            ExpiresAt = DateTimeOffset.UtcNow.AddDays(30) });
        _db.SaveChanges();
        return token;
    }

    private void SetSessionCookie(string token) =>
        Response.Cookies.Append("sessionToken", token, new CookieOptions {
            HttpOnly = true, Secure = true, SameSite = SameSiteMode.Lax,
            MaxAge = TimeSpan.FromDays(30), Path = "/",
        });
}

Database Schema for WebAuthn

model WebAuthnCredential {
  id              String   @id @default(cuid())
  userId          String
  user            User     @relation(fields: [userId], references: [id], onDelete: Cascade)
  credentialID    String   @unique
  publicKeyBytes  String   // Base64URL encoded
  signCount       Int
  transports      String[] // JSON array of transports
  name            String   // User-friendly name
  createdAt       DateTime @default(now())
  updatedAt       DateTime @updatedAt

  @@index([userId])
}

model WebAuthnChallenge {
  id        String   @id @default(cuid())
  userId    String
  user      User     @relation(fields: [userId], references: [id], onDelete: Cascade)
  challenge String
  type      String   // 'registration' or 'authentication'
  used      Boolean  @default(false)
  expiresAt DateTime
  createdAt DateTime @default(now())

  @@index([userId, type, expiresAt])
}
AspectMagic LinksSMS OTPWebAuthn
SecurityVery HighHighHighest (phishing-resistant)
Phishing RiskMedium (email can be spoofed)Low (OTP has expiry)None (public key cryptography)
User FrictionVery Low (1 click)Low (copy-paste)Very Low (biometric)
DependencyEmail deliverySMS/TwilioDevice with authenticator
CostFree/SMTP~$0.05 per SMSFree
RecoveryEmail access requiredSIM swap riskDevice recovery required
Mobile-FriendlyExcellentExcellentExcellent
Desktop-FriendlyExcellentGoodExcellent
Best ForGeneral web appsHigh-security + SMSFinancial services, enterprise

The Hybrid Approach: Passwordless Primary, Password Fallback

Most production systems implement passwordless as the primary authentication method but keep password authentication as a fallback for edge cases (email/phone loss, security key locked).

/**
 * Hybrid authentication endpoint
 * Tries passwordless first, falls back to password
 */
export async function hybridAuth(req: Request, res: Response) {
  const { email, password } = req.body;

  if (!email) {
    return res.status(400).json({ error: 'Email is required' });
  }

  // If password is provided, handle as password auth
  if (password) {
    return handlePasswordAuth(req, res);
  }

  // Otherwise, send magic link
  return requestMagicLink(req, res);
}

async function handlePasswordAuth(req: Request, res: Response) {
  const { email, password } = req.body;

  if (!password) {
    return res.status(400).json({ error: 'Password is required' });
  }

  try {
    const user = await prisma.user.findUnique({
      where: { email },
      include: { password: true },
    });

    if (!user || !user.password) {
      // User hasn't set up password fallback
      return res.status(401).json({
        error: 'User or password not found',
      });
    }

    // Verify password using bcrypt
    const isValid = await bcrypt.compare(password, user.password.hash);
    if (!isValid) {
      return res.status(401).json({
        error: 'Invalid credentials',
      });
    }

    // Create session
    const sessionToken = crypto.randomBytes(32).toString('hex');
    await prisma.session.create({
      data: {
        token: sessionToken,
        userId: user.id,
        expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
        authMethod: 'password',
      },
    });

    res.cookie('sessionToken', sessionToken, {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'lax',
      maxAge: 30 * 24 * 60 * 60 * 1000,
      path: '/',
    });

    logger.info('User authenticated via password fallback', { userId: user.id });

    return res.status(200).json({
      message: 'Authenticated successfully',
      user: { id: user.id, email: user.email },
    });
  } catch (error) {
    logger.error('Error during password authentication', { error });
    return res.status(500).json({
      error: 'Authentication failed',
    });
  }
}
@RestController
@RequestMapping("/auth")
public class HybridAuthController {

    @Autowired private UserRepository userRepo;
    @Autowired private SessionRepository sessionRepo;
    @Autowired private MagicLinkController magicLinkController;
    private final PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();

    @PostMapping("/login")
    public ResponseEntity<Map<String, Object>> hybridAuth(
            @RequestBody Map<String, String> body,
            jakarta.servlet.http.HttpServletResponse response) {
        String email = body.get("email");
        String password = body.get("password");

        if (email == null || email.isBlank())
            return ResponseEntity.badRequest().body(Map.of("error", "Email is required"));

        // If password provided, use password auth; otherwise send magic link
        if (password != null && !password.isBlank())
            return handlePasswordAuth(email, password, response);

        return magicLinkController.requestMagicLink(body);
    }

    private ResponseEntity<Map<String, Object>> handlePasswordAuth(
            String email, String password,
            jakarta.servlet.http.HttpServletResponse response) {
        User user = userRepo.findByEmailWithPassword(email).orElse(null);

        if (user == null || user.getPasswordHash() == null)
            return ResponseEntity.status(401)
                .body(Map.of("error", "User or password not found"));

        if (!passwordEncoder.matches(password, user.getPasswordHash()))
            return ResponseEntity.status(401)
                .body(Map.of("error", "Invalid credentials"));

        SecureRandom sr = new SecureRandom();
        byte[] tokenBytes = new byte[32];
        sr.nextBytes(tokenBytes);
        String sessionToken = HexFormat.of().formatHex(tokenBytes);

        sessionRepo.save(Session.builder()
            .token(sessionToken).userId(user.getId())
            .expiresAt(Instant.now().plus(30, ChronoUnit.DAYS))
            .authMethod("password").build());

        ResponseCookie cookie = ResponseCookie.from("sessionToken", sessionToken)
            .httpOnly(true).secure(true).sameSite("Lax")
            .maxAge(Duration.ofDays(30)).path("/").build();
        response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());

        return ResponseEntity.ok(Map.of(
            "message", "Authenticated successfully",
            "user", Map.of("id", user.getId(), "email", user.getEmail())));
    }
}
from passlib.context import CryptContext

pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")


class LoginRequest(BaseModel):
    email: EmailStr
    password: str | None = None


@router.post("/auth/login")
async def hybrid_auth(
    body: LoginRequest,
    response: Response,
    db: Session = Depends(get_db),
):
    if not body.email:
        raise HTTPException(status_code=400, detail="Email is required")

    # If password provided, use password auth; otherwise send magic link
    if body.password:
        return await handle_password_auth(body.email, body.password, response, db)

    return await request_magic_link(body, db)


async def handle_password_auth(
    email: str, password: str,
    response: Response, db: Session,
) -> dict:
    user = db.query(User).filter_by(email=email).first()

    if not user or not user.password_hash:
        raise HTTPException(status_code=401,
            detail="User or password not found")

    if not pwd_context.verify(password, user.password_hash):
        raise HTTPException(status_code=401, detail="Invalid credentials")

    session_token = secrets.token_hex(32)
    db.add(Session(
        token=session_token, user_id=user.id,
        expires_at=datetime.now(timezone.utc) + timedelta(days=30),
        auth_method="password",
    ))
    db.commit()

    response.set_cookie("sessionToken", session_token,
        httponly=True, secure=True, samesite="lax",
        max_age=30 * 24 * 60 * 60, path="/")

    return {"message": "Authenticated successfully",
            "user": {"id": user.id, "email": user.email}}
[ApiController]
[Route("auth")]
public class HybridAuthController : ControllerBase
{
    private readonly AppDbContext _db;
    private readonly MagicLinkController _magicLink;

    public HybridAuthController(AppDbContext db, MagicLinkController magicLink)
    { _db = db; _magicLink = magicLink; }

    [HttpPost("login")]
    public async Task<IActionResult> HybridAuth([FromBody] LoginRequest body)
    {
        if (string.IsNullOrWhiteSpace(body.Email))
            return BadRequest(new { error = "Email is required" });

        // If password provided, use password auth; otherwise send magic link
        if (!string.IsNullOrWhiteSpace(body.Password))
            return await HandlePasswordAuth(body.Email, body.Password);

        return await _magicLink.RequestMagicLink(new MagicLinkRequest { Email = body.Email });
    }

    private async Task<IActionResult> HandlePasswordAuth(string email, string password)
    {
        var user = await _db.Users
            .Include(u => u.PasswordCredential)
            .FirstOrDefaultAsync(u => u.Email == email);

        if (user?.PasswordCredential == null)
            return Unauthorized(new { error = "User or password not found" });

        if (!BCrypt.Net.BCrypt.Verify(password, user.PasswordCredential.Hash))
            return Unauthorized(new { error = "Invalid credentials" });

        var sessionToken = Convert.ToHexString(
            RandomNumberGenerator.GetBytes(32)).ToLower();
        _db.Sessions.Add(new Session {
            Token = sessionToken, UserId = user.Id,
            ExpiresAt = DateTimeOffset.UtcNow.AddDays(30),
            AuthMethod = "password",
        });
        await _db.SaveChangesAsync();

        Response.Cookies.Append("sessionToken", sessionToken, new CookieOptions {
            HttpOnly = true, Secure = true, SameSite = SameSiteMode.Lax,
            MaxAge = TimeSpan.FromDays(30), Path = "/",
        });

        return Ok(new { message = "Authenticated successfully",
            user = new { user.Id, user.Email } });
    }
}

Security Considerations

Email Deliverability

Magic links depend on email reaching the user’s inbox. Implement:

  • SPF, DKIM, DMARC records for sender reputation
  • Monitor bounce rates
  • Use a dedicated email service (SendGrid, AWS SES)
  • Include unsubscribe link (for compliance)

SIM Swapping

SMS OTP is vulnerable to SIM swapping where attackers socially engineer carriers into transferring the phone number. Mitigation:

  • Use passwordless as primary method
  • SMS OTP for sensitive operations only
  • Require additional verification (security questions, recovery codes)
  • Monitor for unusual authentication patterns

Token Entropy

All random tokens must use cryptographically secure generation:

// Good: cryptographically secure
crypto.randomBytes(32).toString('hex'); // 256 bits of entropy

// Bad: Math.random() is not secure
Math.random().toString(36).substring(7);

// Good: crypto for OTP
crypto.randomInt(0, 999999);
// Good: cryptographically secure
SecureRandom sr = new SecureRandom();
byte[] bytes = new byte[32];
sr.nextBytes(bytes);
String token = HexFormat.of().formatHex(bytes); // 256 bits of entropy

// Bad: Random is not cryptographically secure
// new Random().nextInt()  -- DO NOT USE

// Good: secure OTP
int otp = sr.nextInt(1_000_000); // 0–999999
String otpStr = String.format("%06d", otp);
import secrets, os

# Good: cryptographically secure
token = secrets.token_hex(32)   # 256 bits of entropy
token_bytes = os.urandom(32)    # also fine

# Bad: random is not secure
# import random; random.randint(...)  -- DO NOT USE

# Good: secure OTP
otp = secrets.randbelow(1_000_000)
otp_str = str(otp).zfill(6)
using System.Security.Cryptography;

// Good: cryptographically secure
var bytes = RandomNumberGenerator.GetBytes(32);
var token = Convert.ToHexString(bytes).ToLower(); // 256 bits of entropy

// Bad: System.Random is not cryptographically secure
// new Random().Next()  -- DO NOT USE

// Good: secure OTP
int otp = RandomNumberGenerator.GetInt32(1_000_000); // 0–999999
string otpStr = otp.ToString("D6");

Rate Limiting & Abuse Prevention

Implement multiple layers:

// Per-user rate limiting
const rateLimiter = new RateLimiter({
  windowMs: 60 * 60 * 1000, // 1 hour
  max: 5, // max 5 requests per hour
});

// IP-based rate limiting for registration
const registrationLimiter = new RateLimiter({
  windowMs: 60 * 60 * 1000,
  max: 10, // max 10 registrations per hour per IP
  keyGenerator: (req) => req.ip,
});
// Spring Boot + Bucket4j
import io.github.bucket4j.*;
import java.time.Duration;

@Component
public class RateLimiterService {
    private final Map<String, Bucket> userBuckets = new ConcurrentHashMap<>();
    private final Map<String, Bucket> ipBuckets = new ConcurrentHashMap<>();

    // Per-user: max 5 requests per hour
    public Bucket getUserBucket(String email) {
        return userBuckets.computeIfAbsent(email, k ->
            Bucket.builder()
                .addLimit(Bandwidth.classic(5, Refill.greedy(5, Duration.ofHours(1))))
                .build());
    }

    // Per-IP: max 10 registrations per hour
    public Bucket getIpBucket(String ip) {
        return ipBuckets.computeIfAbsent(ip, k ->
            Bucket.builder()
                .addLimit(Bandwidth.classic(10, Refill.greedy(10, Duration.ofHours(1))))
                .build());
    }

    public boolean tryConsume(Bucket bucket) {
        return bucket.tryConsume(1);
    }
}
from slowapi import Limiter
from slowapi.util import get_remote_address

limiter = Limiter(key_func=get_remote_address)

# Per-user rate limiting (5 requests per hour)
@router.post("/auth/magic-link")
@limiter.limit("5/hour", key_func=lambda req: req.state.email)
async def request_magic_link(request: Request, body: MagicLinkRequest):
    ...

# IP-based rate limiting for registration (10 per hour)
@router.post("/auth/register")
@limiter.limit("10/hour")
async def register(request: Request, body: RegisterRequest):
    ...
// ASP.NET Core rate limiting (built-in .NET 7+)
builder.Services.AddRateLimiter(options => {
    // Per-user: 5 requests per hour
    options.AddPolicy("per-user-auth", context => {
        var email = context.Request.Form["email"].ToString();
        return RateLimitPartition.GetFixedWindowLimiter(email, _ =>
            new FixedWindowRateLimiterOptions {
                PermitLimit = 5,
                Window = TimeSpan.FromHours(1),
            });
    });

    // Per-IP: 10 registrations per hour
    options.AddPolicy("per-ip-registration", context => {
        var ip = context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
        return RateLimitPartition.GetFixedWindowLimiter(ip, _ =>
            new FixedWindowRateLimiterOptions {
                PermitLimit = 10,
                Window = TimeSpan.FromHours(1),
            });
    });
});

// Apply on controller actions:
// [EnableRateLimiting("per-user-auth")]

Production Checklist

  • Use HTTPS everywhere (TLS 1.3+)
  • Implement CSRF protection (SameSite cookies, CSRF tokens)
  • Set secure cookie flags (httpOnly, secure, sameSite)
  • Implement rate limiting on all auth endpoints
  • Monitor for suspicious authentication patterns
  • Log all authentication attempts (without sensitive data)
  • Implement exponential backoff for failed attempts
  • Use short expiry windows (15 min for magic links, 10 min for OTP)
  • Hash tokens before storing in database
  • Regular security audits and penetration testing
  • Monitor OWASP Top 10 for emerging threats
  • Implement account recovery mechanisms
  • Test database encryption at rest
  • Set up alerts for unusual access patterns
  • Document security policies for team
  • Regular dependency updates and security patches

Conclusion

Passwordless authentication represents a major improvement over password-based systems. Magic links offer excellent UX with good security, SMS OTP provides higher assurance, and WebAuthn delivers the strongest security with modern UX.

The best approach combines multiple methods: prioritize WebAuthn for users with compatible devices, fall back to magic links for general users, and keep password authentication as a final fallback for recovery scenarios.

Implement these patterns with careful attention to rate limiting, token expiry, and secure storage, and your users will enjoy both better security and better authentication experience.

Comments powered by Giscus are not yet configured. Set PUBLIC_GISCUS_REPO_ID and PUBLIC_GISCUS_CATEGORY_ID in apps/web/.env to enable.

PV

Written by Palakorn Voramongkol

Software Engineer Specialist with 20+ years of experience. Writing about architecture, performance, and building production systems.

More about me

Continue Reading