Back to all posts
June 13, 2025Charlie BrownDevelopment

Magic Link Authentication: A Passwordless Approach with NestJS

Learn how to implement secure passwordless authentication using magic links in NestJS, eliminating password management overhead while maintaining security.

Magic Link Authentication: A Passwordless Approach with NestJS

Magic Link Authentication: A Passwordless Approach with NestJS

Passwordless authentication is becoming the standard for modern web applications. Magic links offer a seamless user experience while eliminating the security risks associated with password storage and management. In this article, we'll explore how to implement a robust magic link authentication system using NestJS, PostgreSQL, and email services.

Traditional password-based authentication comes with several challenges:

  • Users must remember complex passwords
  • Password reset flows are cumbersome
  • Security risks from password breaches
  • Additional infrastructure for password hashing and validation

Magic links solve these problems by sending a time-limited, single-use token to the user's email. When clicked, the token is verified and the user is authenticated without ever entering a password.

Architecture Overview

Our implementation consists of three main components:

  1. Login Endpoint: Generates a unique token and sends it via email
  2. Verification Endpoint: Validates the token and issues JWT tokens
  3. Token Management: Secure storage and expiration handling

Implementation

Database Schema

First, we need a Verification entity to store our magic link tokens:

typescript
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
import { User } from './user.entity';

export enum VerificationType {
  LOGIN = 'login',
  PASSWORD_RESET = 'password_reset',
}

@Entity('verifications')
export class Verification {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @ManyToOne(() => User)
  @JoinColumn({ name: 'user_id' })
  user: User;

  @Column({ type: 'varchar', length: 255 })
  token: string;

  @Column({ type: 'enum', enum: VerificationType })
  type: VerificationType;

  @Column({ type: 'timestamp', name: 'expires_at' })
  expiresAt: Date;

  @Column({ type: 'boolean', default: false })
  used: boolean;

  @Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP', name: 'created_at' })
  createdAt: Date;
}

Auth Service

The core authentication logic handles token generation, validation, and JWT issuance:

typescript
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { v4 as uuidv4 } from 'uuid';
import { User } from '../users/entities/user.entity';
import { Verification } from '../users/entities/verification.entity';
import { EmailService } from '../email/email.service';

@Injectable()
export class AuthService {
  constructor(
    @InjectRepository(User)
    private userRepository: Repository<User>,
    @InjectRepository(Verification)
    private verificationRepository: Repository<Verification>,
    private jwtService: JwtService,
    private emailService: EmailService,
  ) {}

  async login(email: string): Promise<{ message: string }> {
    const user = await this.userRepository.findOne({
      where: { email: email.toLowerCase() },
    });

    if (!user) {
      throw new NotFoundException('User not found');
    }

    // Generate secure UUID token
    const token = uuidv4();
    const expiresAt = new Date();
    expiresAt.setMinutes(expiresAt.getMinutes() + 15); // 15-minute expiry

    // Invalidate any existing login tokens for security
    await this.verificationRepository.update(
      {
        user: { id: user.id },
        type: VerificationType.LOGIN,
        used: false,
      },
      { used: true },
    );

    // Create new verification record
    const verification = this.verificationRepository.create({
      user,
      token,
      type: VerificationType.LOGIN,
      expiresAt,
      used: false,
    });

    await this.verificationRepository.save(verification);

    // Send magic link email
    await this.emailService.sendMagicLink(
      user.email,
      user.firstName || user.email,
      token,
    );

    return { message: 'Magic link sent to your email' };
  }

  async verify(token: string): Promise<{
    access_token: string;
    refresh_token: string;
    user: Partial<User>;
  }> {
    const verification = await this.verificationRepository.findOne({
      where: { token, type: VerificationType.LOGIN },
      relations: ['user'],
    });

    if (!verification) {
      throw new BadRequestException('Invalid verification token');
    }

    if (verification.used) {
      throw new BadRequestException('Verification token already used');
    }

    if (verification.expiresAt < new Date()) {
      throw new BadRequestException('Verification token expired');
    }

    const user = verification.user;

    // Mark token as used (single-use token)
    verification.used = true;
    await this.verificationRepository.save(verification);

    // Generate JWT tokens
    const payload = { sub: user.id, email: user.email, role: user.role };
    const accessToken = this.jwtService.sign(payload);
    const refreshToken = this.jwtService.sign(payload, {
      secret: process.env.JWT_REFRESH_SECRET,
      expiresIn: '7d',
    });

    return {
      access_token: accessToken,
      refresh_token: refreshToken,
      user: {
        id: user.id,
        email: user.email,
        firstName: user.firstName,
        lastName: user.lastName,
        role: user.role,
      },
    };
  }
}

Email Service Integration

Using Resend (or any email service), we send the magic link:

typescript
import { Injectable } from '@nestjs/common';
import { Resend } from 'resend';

@Injectable()
export class EmailService {
  private resend: Resend;

  constructor() {
    this.resend = new Resend(process.env.RESEND_API_KEY);
  }

  async sendMagicLink(email: string, name: string, token: string): Promise<void> {
    const magicLink = `${process.env.FRONTEND_URL}/auth/verify?token=${token}`;

    await this.resend.emails.send({
      from: 'noreply@yourdomain.com',
      to: email,
      subject: 'Sign in to your account',
      html: `
        <h1>Hello ${name}!</h1>
        <p>Click the link below to sign in:</p>
        <a href="${magicLink}">Sign In</a>
        <p>This link expires in 15 minutes.</p>
        <p>If you didn't request this, please ignore this email.</p>
      `,
    });
  }
}

Security Considerations

  1. Token Expiration: Tokens expire after 15 minutes to limit exposure
  2. Single-Use Tokens: Once used, tokens are marked as used and cannot be reused
  3. Token Invalidation: Previous unused tokens are invalidated when a new one is generated
  4. UUID Tokens: Using UUIDs ensures tokens are cryptographically random and unguessable
  5. HTTPS Only: Always use HTTPS in production to protect tokens in transit

Frontend Integration

On the frontend, handle the verification flow:

typescript
// Login page
async function handleLogin(email: string) {
  await fetch('/api/auth/login', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email }),
  });
  // Show success message
}

// Verification page
async function handleVerify(token: string) {
  const response = await fetch('/api/auth/verify', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ token }),
  });
  
  const { access_token, refresh_token } = await response.json();
  
  // Store tokens securely (httpOnly cookies recommended)
  document.cookie = `access_token=${access_token}; Secure; SameSite=Strict`;
  document.cookie = `refresh_token=${refresh_token}; Secure; SameSite=Strict`;
  
  // Redirect to dashboard
  window.location.href = '/dashboard';
}

Benefits of This Approach

  • Enhanced Security: No passwords to breach or hash
  • Better UX: Users don't need to remember passwords
  • Reduced Support: Fewer password reset requests
  • Compliance: Easier to meet security standards (no password storage)

Conclusion

Magic link authentication provides a modern, secure alternative to traditional password-based systems. By implementing token-based verification with proper expiration and single-use constraints, we create a robust authentication flow that enhances both security and user experience.

For production deployments, consider adding rate limiting, monitoring for suspicious activity, and implementing additional security measures like IP validation or device fingerprinting.

References

Want more insights?

Subscribe to our newsletter or follow us for more updates on software development and team scaling.

Contact Us