[TERMINAL · SKILLS]
> mounting /skills...
> indexing 295 manifests...
> linking agents: claude · codex · gemini · cursor
> ready.
[░░░░░░░░░░░░░░░░░░░░░░░░░░░░] 0%
Terminal.skills
Use Cases/Build Magic Link Authentication

Build Magic Link Authentication

Build a passwordless authentication system with magic links — generating secure tokens, rate-limited email delivery, one-click login, session management, and device fingerprinting.

#redis#caching#database#pub-sub#queues
Works with:claude-codeopenai-codexgemini-clicursor

Skills stack · 5 skills

Avg quality 93/100·All SAFE
>

typescript

v

Not yet scored
View skill
>

redis

v1.0.0

Build applications with Redis — caching, session storage, pub/sub, streams, rate limiting, leaderboards, and queues. Use when tasks involve in-memory data storage, real-time messaging, distributed locking, or performance optimization with caching layers.

93/100 quality
1.81× impact
SAFE
View skill
>

postgresql

v1.0.0

Assists with designing schemas, writing performant queries, managing indexes, and operating PostgreSQL databases. Use when working with JSONB, full-text search, window functions, CTEs, row-level security, replication, or performance tuning. Trigger words: postgresql, postgres, sql, database, jsonb, rls, window functions, cte.

87/100 quality
1.53× impact
SAFE
View skill
>

hono

v1.0.0

You are an expert in Hono, the ultrafast web framework for the edge. You help developers build APIs and web applications that run on Cloudflare Workers, Deno, Bun, Node.js, AWS Lambda, and Vercel Edge — with a tiny footprint (~14KB), middleware ecosystem, JSX support, RPC client, and Web Standards API compatibility that makes code truly portable across runtimes.

93/100 quality
3.00× impact
SAFE
View skill
>

zod

v1.0.0

You are an expert in Zod, the TypeScript-first schema declaration and validation library. You help developers define schemas that validate data at runtime AND infer TypeScript types at compile time — eliminating the need to write types and validators separately. Used for API input validation, form validation, environment variables, config files, and any data boundary.

100/100 quality
1.21× impact
SAFE
View skill
$

The Problem

Yuki leads product at a 20-person SaaS. Password-related support tickets eat 15 hours/week: "forgot password", "account locked", "password doesn't work." 40% of signup attempts abandon at the password creation step. Password reuse means compromised credentials on other sites threaten their users. Slack, Notion, and Linear all use magic links — the user enters their email, gets a link, clicks it, and they're in. No password to remember, forget, or steal.

Step 1: Build the Magic Link System

typescript
// src/auth/magic-link.ts — Passwordless auth with secure tokens and session management
import { randomBytes, createHash, timingSafeEqual } from "node:crypto";
import { pool } from "../db";
import { Redis } from "ioredis";
import { SignJWT, jwtVerify } from "jose";

const redis = new Redis(process.env.REDIS_URL!);

const TOKEN_EXPIRY_MINUTES = 10;
const MAX_ATTEMPTS_PER_HOUR = 5;
const SESSION_DURATION_DAYS = 30;
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET!);

// Request magic link
export async function requestMagicLink(email: string, metadata?: {
  userAgent?: string;
  ip?: string;
  redirectTo?: string;
}): Promise<{ sent: boolean; retryAfterSeconds?: number }> {
  const normalizedEmail = email.toLowerCase().trim();

  // Rate limit by email
  const rateKey = `magic:rate:${normalizedEmail}`;
  const attempts = parseInt(await redis.get(rateKey) || "0");
  if (attempts >= MAX_ATTEMPTS_PER_HOUR) {
    const ttl = await redis.ttl(rateKey);
    return { sent: false, retryAfterSeconds: Math.max(ttl, 60) };
  }

  // Rate limit by IP (prevent enumeration)
  if (metadata?.ip) {
    const ipKey = `magic:rate:ip:${metadata.ip}`;
    const ipAttempts = parseInt(await redis.get(ipKey) || "0");
    if (ipAttempts >= 10) {
      return { sent: false, retryAfterSeconds: 3600 };
    }
    await redis.multi().incr(ipKey).expire(ipKey, 3600).exec();
  }

  // Generate token (32 bytes = 256 bits)
  const token = randomBytes(32).toString("urlsafe-base64");
  const tokenHash = createHash("sha256").update(token).digest("hex");

  // Invalidate previous tokens for this email
  await redis.del(`magic:email:${normalizedEmail}`);

  // Store hashed token with metadata
  const expiresAt = Date.now() + TOKEN_EXPIRY_MINUTES * 60000;
  await redis.setex(`magic:token:${tokenHash}`, TOKEN_EXPIRY_MINUTES * 60, JSON.stringify({
    email: normalizedEmail,
    expiresAt,
    userAgent: metadata?.userAgent || "",
    ip: metadata?.ip || "",
    redirectTo: metadata?.redirectTo || "/",
    attempts: 0,
  }));

  // Track active token for this email (for invalidation)
  await redis.setex(`magic:email:${normalizedEmail}`, TOKEN_EXPIRY_MINUTES * 60, tokenHash);

  // Build magic link URL
  const magicLink = `${process.env.APP_URL}/auth/verify?token=${token}`;

  // Queue email
  await redis.rpush("email:queue", JSON.stringify({
    type: "magic_link",
    to: normalizedEmail,
    subject: "Your login link",
    magicLink,
    expiresIn: `${TOKEN_EXPIRY_MINUTES} minutes`,
    requestedFrom: metadata?.ip ? `IP: ${metadata.ip.slice(0, 8)}...` : "Unknown",
  }));

  // Increment rate limit
  await redis.multi().incr(rateKey).expire(rateKey, 3600).exec();

  return { sent: true };
}

// Verify magic link and create session
export async function verifyMagicLink(token: string, metadata?: {
  userAgent?: string;
  ip?: string;
  deviceFingerprint?: string;
}): Promise<{
  success: boolean;
  sessionToken?: string;
  redirectTo?: string;
  error?: string;
}> {
  const tokenHash = createHash("sha256").update(token).digest("hex");

  // Get token data
  const tokenData = await redis.get(`magic:token:${tokenHash}`);
  if (!tokenData) {
    return { success: false, error: "Invalid or expired link. Please request a new one." };
  }

  const data = JSON.parse(tokenData);

  // Check expiry
  if (Date.now() > data.expiresAt) {
    await redis.del(`magic:token:${tokenHash}`);
    return { success: false, error: "This link has expired. Please request a new one." };
  }

  // Prevent brute force (max 3 verification attempts per token)
  data.attempts++;
  if (data.attempts > 3) {
    await redis.del(`magic:token:${tokenHash}`);
    return { success: false, error: "Too many attempts. Please request a new link." };
  }
  await redis.setex(`magic:token:${tokenHash}`, TOKEN_EXPIRY_MINUTES * 60, JSON.stringify(data));

  // Invalidate token (single use)
  await redis.del(`magic:token:${tokenHash}`);
  await redis.del(`magic:email:${data.email}`);

  // Find or create user
  let { rows: [user] } = await pool.query(
    "SELECT id, email, name FROM users WHERE email = $1",
    [data.email]
  );

  if (!user) {
    // Auto-create account on first magic link login
    const { rows: [newUser] } = await pool.query(
      `INSERT INTO users (id, email, email_verified, created_at)
       VALUES ($1, $2, true, NOW()) RETURNING *`,
      [`user-${Date.now()}`, data.email]
    );
    user = newUser;
  } else {
    // Mark email as verified
    await pool.query("UPDATE users SET email_verified = true WHERE id = $1", [user.id]);
  }

  // Create session
  const sessionId = randomBytes(32).toString("hex");
  const expiresAt = new Date(Date.now() + SESSION_DURATION_DAYS * 86400000);

  await pool.query(
    `INSERT INTO sessions (id, user_id, user_agent, ip_address, device_fingerprint, expires_at, created_at)
     VALUES ($1, $2, $3, $4, $5, $6, NOW())`,
    [sessionId, user.id, metadata?.userAgent || null, metadata?.ip || null,
     metadata?.deviceFingerprint || null, expiresAt]
  );

  // Generate JWT
  const jwt = await new SignJWT({
    sub: user.id,
    email: user.email,
    sid: sessionId,
  })
    .setProtectedHeader({ alg: "HS256" })
    .setIssuedAt()
    .setExpirationTime(`${SESSION_DURATION_DAYS}d`)
    .sign(JWT_SECRET);

  // Log authentication event
  await pool.query(
    `INSERT INTO auth_events (user_id, event_type, ip_address, user_agent, created_at)
     VALUES ($1, 'magic_link_login', $2, $3, NOW())`,
    [user.id, metadata?.ip || null, metadata?.userAgent || null]
  );

  return {
    success: true,
    sessionToken: jwt,
    redirectTo: data.redirectTo,
  };
}

// List active sessions (for "manage devices" UI)
export async function getActiveSessions(userId: string): Promise<Array<{
  id: string;
  userAgent: string;
  ipAddress: string;
  createdAt: string;
  lastActiveAt: string;
  isCurrent: boolean;
}>> {
  const { rows } = await pool.query(
    `SELECT id, user_agent, ip_address, created_at, last_active_at
     FROM sessions
     WHERE user_id = $1 AND expires_at > NOW() AND revoked = false
     ORDER BY last_active_at DESC`,
    [userId]
  );
  return rows;
}

// Revoke a session (logout from specific device)
export async function revokeSession(sessionId: string, userId: string): Promise<void> {
  await pool.query(
    "UPDATE sessions SET revoked = true WHERE id = $1 AND user_id = $2",
    [sessionId, userId]
  );
}

// Revoke all sessions except current (security action)
export async function revokeAllSessions(userId: string, exceptSessionId?: string): Promise<number> {
  const { rowCount } = await pool.query(
    `UPDATE sessions SET revoked = true
     WHERE user_id = $1 AND revoked = false ${exceptSessionId ? `AND id != $2` : ""}`,
    exceptSessionId ? [userId, exceptSessionId] : [userId]
  );
  return rowCount || 0;
}

Results

  • Password support tickets eliminated — no passwords means no "forgot password", no "account locked", no "password doesn't work"; 15 hours/week of support freed up
  • Signup completion: 60% → 92% — entering email is the only step; no password requirements, no "must contain uppercase and special character" friction
  • Security improved — no password database to breach; no credential stuffing attacks; tokens expire in 10 minutes and are single-use
  • Session management gives users control — "Manage devices" page shows all active sessions; users can revoke access from any device; security-conscious enterprise customers love this
  • Auto-provisioning — first-time users are automatically created when they click a magic link; no separate signup flow needed