[{"data":1,"prerenderedAt":342},["ShallowReactive",2],{"blog-how-to/magic-links":3},{"id":4,"title":5,"body":6,"category":323,"date":324,"dateModified":324,"description":325,"draft":326,"extension":327,"faq":328,"featured":326,"headerVariant":329,"image":328,"keywords":328,"meta":330,"navigation":331,"ogDescription":332,"ogTitle":328,"path":333,"readTime":328,"schemaOrg":334,"schemaType":335,"seo":336,"sitemap":337,"stem":338,"tags":339,"twitterCard":340,"__hash__":341},"blog/blog/how-to/magic-links.md","How to Implement Magic Link Authentication",{"type":7,"value":8,"toc":307},"minimark",[9,13,17,21,27,30,43,48,51,55,75,88,101,114,127,140,153,182,186,214,218,223,226,230,233,237,240,244,247,269,288],[10,11],"category-badge",{"category":12},"How-To Guide",[14,15,5],"h1",{"id":16},"how-to-implement-magic-link-authentication",[18,19,20],"p",{},"Passwordless login that's both secure and user-friendly",[22,23,24],"tldr",{},[18,25,26],{},"TL;DR (25 minutes):\nGenerate 32+ byte random tokens, store hashed with 15-minute expiration, send via email with clear context, verify and invalidate in a single atomic operation, rate limit to 3-5 requests per email per hour. Magic links trade password security for email security - only use when that tradeoff makes sense.",[18,28,29],{},"Prerequisites:",[31,32,33,37,40],"ul",{},[34,35,36],"li",{},"Email sending capability (Resend, SendGrid, etc.)",[34,38,39],{},"Database for token storage",[34,41,42],{},"HTTPS-enabled domain",[44,45,47],"h2",{"id":46},"why-this-matters","Why This Matters",[18,49,50],{},"Magic links eliminate password-related vulnerabilities: weak passwords, password reuse, and credential stuffing. But they shift security to email - if someone has access to the user's email, they can log in. Implement magic links carefully with proper rate limiting and token security.",[44,52,54],{"id":53},"step-by-step-guide","Step-by-Step Guide",[56,57,59,64],"step",{"number":58},"1",[60,61,63],"h3",{"id":62},"create-the-database-schema","Create the database schema",[65,66,71],"pre",{"className":67,"code":69,"language":70},[68],"language-text","// Prisma schema\nmodel MagicLink {\n  id        String   @id @default(cuid())\n  tokenHash String   @unique  // Store hashed, not raw\n  email     String\n  expiresAt DateTime\n  usedAt    DateTime?\n  createdAt DateTime @default(now())\n\n  @@index([email])\n  @@index([expiresAt])\n}\n\nmodel User {\n  id            String    @id @default(cuid())\n  email         String    @unique\n  emailVerified DateTime?\n  // ... other fields\n}\n","text",[72,73,69],"code",{"__ignoreMap":74},"",[56,76,78,82],{"number":77},"2",[60,79,81],{"id":80},"generate-secure-tokens","Generate secure tokens",[65,83,86],{"className":84,"code":85,"language":70},[68],"import crypto from 'crypto';\n\n// Generate cryptographically secure token\nfunction generateMagicLinkToken(): string {\n  return crypto.randomBytes(32).toString('hex');\n}\n\n// Hash token for storage\nfunction hashToken(token: string): string {\n  return crypto.createHash('sha256').update(token).digest('hex');\n}\n\n// Create magic link\nasync function createMagicLink(email: string) {\n  const token = generateMagicLinkToken();\n  const tokenHash = hashToken(token);\n\n  // Delete any existing unused tokens for this email\n  await prisma.magicLink.deleteMany({\n    where: {\n      email,\n      usedAt: null\n    }\n  });\n\n  // Create new token with 15-minute expiration\n  await prisma.magicLink.create({\n    data: {\n      tokenHash,\n      email: email.toLowerCase(),\n      expiresAt: new Date(Date.now() + 15 * 60 * 1000)\n    }\n  });\n\n  // Return the raw token (for the URL)\n  return token;\n}\n",[72,87,85],{"__ignoreMap":74},[56,89,91,95],{"number":90},"3",[60,92,94],{"id":93},"implement-rate-limiting","Implement rate limiting",[65,96,99],{"className":97,"code":98,"language":70},[68],"import { Ratelimit } from '@upstash/ratelimit';\nimport { Redis } from '@upstash/redis';\n\nconst redis = new Redis({\n  url: process.env.UPSTASH_REDIS_URL,\n  token: process.env.UPSTASH_REDIS_TOKEN\n});\n\n// Rate limit per email: 3 requests per hour\nconst emailRateLimiter = new Ratelimit({\n  redis,\n  limiter: Ratelimit.slidingWindow(3, '1 h')\n});\n\n// Rate limit per IP: 10 requests per hour\nconst ipRateLimiter = new Ratelimit({\n  redis,\n  limiter: Ratelimit.slidingWindow(10, '1 h')\n});\n\nasync function checkRateLimits(email: string, ip: string) {\n  const [emailLimit, ipLimit] = await Promise.all([\n    emailRateLimiter.limit(email),\n    ipRateLimiter.limit(ip)\n  ]);\n\n  if (!emailLimit.success) {\n    return {\n      allowed: false,\n      message: 'Too many login attempts for this email. Please try again later.'\n    };\n  }\n\n  if (!ipLimit.success) {\n    return {\n      allowed: false,\n      message: 'Too many requests. Please try again later.'\n    };\n  }\n\n  return { allowed: true };\n}\n",[72,100,98],{"__ignoreMap":74},[56,102,104,108],{"number":103},"4",[60,105,107],{"id":106},"request-magic-link-endpoint","Request magic link endpoint",[65,109,112],{"className":110,"code":111,"language":70},[68],"import { z } from 'zod';\n\nconst requestSchema = z.object({\n  email: z.string().email().toLowerCase()\n});\n\nasync function requestMagicLink(req, res) {\n  const result = requestSchema.safeParse(req.body);\n  if (!result.success) {\n    return res.status(400).json({ error: 'Invalid email address' });\n  }\n\n  const { email } = result.data;\n\n  // Check rate limits\n  const rateLimit = await checkRateLimits(email, req.ip);\n  if (!rateLimit.allowed) {\n    return res.status(429).json({ error: rateLimit.message });\n  }\n\n  // Always return success to prevent email enumeration\n  // Even if user doesn't exist, we show the same message\n  const userExists = await prisma.user.findUnique({\n    where: { email }\n  });\n\n  if (userExists) {\n    const token = await createMagicLink(email);\n    const magicLink = `${process.env.APP_URL}/auth/verify?token=${token}`;\n\n    await sendMagicLinkEmail(email, magicLink);\n  }\n\n  // Same response regardless of whether user exists\n  return res.json({\n    message: 'If an account exists, a login link has been sent to your email.'\n  });\n}\n",[72,113,111],{"__ignoreMap":74},[56,115,117,121],{"number":116},"5",[60,118,120],{"id":119},"send-the-email","Send the email",[65,122,125],{"className":123,"code":124,"language":70},[68],"import { Resend } from 'resend';\n\nconst resend = new Resend(process.env.RESEND_API_KEY);\n\nasync function sendMagicLinkEmail(email: string, magicLink: string) {\n  await resend.emails.send({\n    from: 'MyApp ',\n    to: email,\n    subject: 'Your login link for MyApp',\n    html: `\n      Sign in to MyApp\n      Click the button below to sign in. This link expires in 15 minutes.\n\n      \n        Sign in to MyApp\n      \n\n      \n        If you didn't request this link, you can safely ignore this email.\n        Someone may have typed your email address by mistake.\n      \n\n      \n        Link not working? Copy and paste this URL:\n        ${magicLink}\n      \n\n      \n\n      \n        This link expires in 15 minutes and can only be used once.\n        Never share this link with anyone.\n      \n    `\n  });\n}\n",[72,126,124],{"__ignoreMap":74},[56,128,130,134],{"number":129},"6",[60,131,133],{"id":132},"verify-the-magic-link","Verify the magic link",[65,135,138],{"className":136,"code":137,"language":70},[68],"async function verifyMagicLink(req, res) {\n  const { token } = req.query;\n\n  if (!token || typeof token !== 'string') {\n    return res.redirect('/login?error=invalid_link');\n  }\n\n  const tokenHash = hashToken(token);\n\n  // Find and validate token atomically\n  const magicLink = await prisma.magicLink.findUnique({\n    where: { tokenHash }\n  });\n\n  // Check if token exists\n  if (!magicLink) {\n    return res.redirect('/login?error=invalid_link');\n  }\n\n  // Check if already used\n  if (magicLink.usedAt) {\n    return res.redirect('/login?error=link_already_used');\n  }\n\n  // Check if expired\n  if (magicLink.expiresAt \u003C new Date()) {\n    return res.redirect('/login?error=link_expired');\n  }\n\n  // Mark as used immediately (prevent race conditions)\n  await prisma.magicLink.update({\n    where: { id: magicLink.id },\n    data: { usedAt: new Date() }\n  });\n\n  // Find or create user\n  let user = await prisma.user.findUnique({\n    where: { email: magicLink.email }\n  });\n\n  if (!user) {\n    user = await prisma.user.create({\n      data: {\n        email: magicLink.email,\n        emailVerified: new Date()  // Email is verified by using magic link\n      }\n    });\n  } else if (!user.emailVerified) {\n    // Mark email as verified\n    await prisma.user.update({\n      where: { id: user.id },\n      data: { emailVerified: new Date() }\n    });\n  }\n\n  // Create session\n  const session = await createSession(user.id, req);\n  setSessionCookie(res, session.sessionId, session.expiresAt);\n\n  return res.redirect('/dashboard');\n}\n",[72,139,137],{"__ignoreMap":74},[56,141,143,147],{"number":142},"7",[60,144,146],{"id":145},"clean-up-expired-tokens","Clean up expired tokens",[65,148,151],{"className":149,"code":150,"language":70},[68],"// Run periodically (cron job or scheduled function)\nasync function cleanupExpiredTokens() {\n  const result = await prisma.magicLink.deleteMany({\n    where: {\n      OR: [\n        { expiresAt: { lt: new Date() } },\n        // Also clean up used tokens older than 24 hours\n        {\n          usedAt: { not: null },\n          createdAt: { lt: new Date(Date.now() - 24 * 60 * 60 * 1000) }\n        }\n      ]\n    }\n  });\n\n  console.log(`Cleaned up ${result.count} magic link tokens`);\n}\n",[72,152,150],{"__ignoreMap":74},[154,155,156,159],"warning-box",{},[18,157,158],{},"Magic Link Security Considerations:",[31,160,161,164,167,170,173,176,179],{},[34,162,163],{},"Short expiration (15 minutes max) - reduces window for interception",[34,165,166],{},"Single use - token invalidated after first use",[34,168,169],{},"Rate limiting - prevent abuse and enumeration",[34,171,172],{},"Hash tokens - raw tokens never stored in database",[34,174,175],{},"HTTPS only - tokens should never travel over HTTP",[34,177,178],{},"Clear email messaging - help users identify phishing",[34,180,181],{},"Consider adding device fingerprinting for suspicious logins",[44,183,185],{"id":184},"how-to-verify-it-worked","How to Verify It Worked",[187,188,189,196,202,208],"ol",{},[34,190,191,195],{},[192,193,194],"strong",{},"Test single use:"," Use a magic link, try using it again - should fail",[34,197,198,201],{},[192,199,200],{},"Test expiration:"," Wait 15+ minutes, try the link - should fail",[34,203,204,207],{},[192,205,206],{},"Test rate limiting:"," Request 4+ links quickly - should be blocked",[34,209,210,213],{},[192,211,212],{},"Check token storage:"," Verify only hashes are in database",[44,215,217],{"id":216},"common-errors-troubleshooting","Common Errors & Troubleshooting",[219,220,222],"h4",{"id":221},"links-always-show-as-expired","Links always show as expired",[18,224,225],{},"Check server time synchronization. Time differences between servers can cause premature expiration.",[219,227,229],{"id":228},"emails-not-arriving","Emails not arriving",[18,231,232],{},"Check spam folders, verify sender domain authentication (SPF, DKIM, DMARC), and check email service logs.",[219,234,236],{"id":235},"race-condition-link-used-twice","Race condition - link used twice",[18,238,239],{},"Use database transactions or atomic operations to check and mark as used in a single query.",[219,241,243],{"id":242},"links-breaking-in-email","Links breaking in email",[18,245,246],{},"Some email clients break long URLs. Include a copy-paste option and consider shorter tokens (UUID v4).",[248,249,250,257,263],"faq-section",{},[251,252,254],"faq-item",{"question":253},"Magic links vs passwords - which is more secure?",[18,255,256],{},"It depends on your users. Magic links eliminate password-related attacks but shift security to email. If users have strong email security (2FA), magic links are great. If not, you're trusting their email provider's security.",[251,258,260],{"question":259},"Should I offer both magic links and passwords?",[18,261,262],{},"Yes, many apps offer both. Users can choose their preference. Consider requiring 2FA if users set a password.",[251,264,266],{"question":265},"How do I handle mobile email apps?",[18,267,268],{},"Mobile email apps often preview links, potentially \"using\" them. Consider: longer tokens that are harder to guess if previewed, or a confirmation page before consuming the token.",[18,270,271,274,279,280,279,284],{},[192,272,273],{},"Related guides:",[275,276,278],"a",{"href":277},"/blog/how-to/session-management","Session Management"," ·\n",[275,281,283],{"href":282},"/blog/how-to/oauth-setup","OAuth Setup",[275,285,287],{"href":286},"/blog/how-to/two-factor-auth","Two-Factor Auth",[289,290,291,297,302],"related-articles",{},[292,293],"related-card",{"description":294,"href":295,"title":296},"Complete guide to secure Auth0 setup. Configure applications, handle callbacks safely, validate tokens, implement author","/blog/how-to/auth0-basics","How to Set Up Auth0 Securely",[292,298],{"description":299,"href":300,"title":301},"Step-by-step guide to storing and retrieving secrets with AWS Secrets Manager. Secure your API keys, database credential","/blog/how-to/aws-secrets-manager","How to Use AWS Secrets Manager",[292,303],{"description":304,"href":305,"title":306},"Step-by-step guide to SSL certificate renewal. Set up automatic renewal with Certbot, monitor expiration dates, and trou","/blog/how-to/certificate-renewal","How to Handle SSL Certificate Renewal",{"title":74,"searchDepth":308,"depth":308,"links":309},2,[310,311,321,322],{"id":46,"depth":308,"text":47},{"id":53,"depth":308,"text":54,"children":312},[313,315,316,317,318,319,320],{"id":62,"depth":314,"text":63},3,{"id":80,"depth":314,"text":81},{"id":93,"depth":314,"text":94},{"id":106,"depth":314,"text":107},{"id":119,"depth":314,"text":120},{"id":132,"depth":314,"text":133},{"id":145,"depth":314,"text":146},{"id":184,"depth":308,"text":185},{"id":216,"depth":308,"text":217},"how-to","2026-01-16","Step-by-step guide to implementing secure magic link authentication. Passwordless login via email with proper security controls.",false,"md",null,"yellow",{},true,"Passwordless authentication with secure magic links.","/blog/how-to/magic-links","[object Object]","HowTo",{"title":5,"description":325},{"loc":333},"blog/how-to/magic-links",[],"summary_large_image","Ia_laYpXQQ05l37OOggcP8zfjnTgEX6SCtPEyBIy2SU",1775843928274]