パスワードを使わないマジックリンク認証をNestJSで実装する方法を解説します。メールに送信される認証リンクをクリックするだけでログインできる、ユーザーフレンドリーな認証システムをpassport-magic-linkを使って構築します。
🎯 この記事で実装できること:
- パスワード不要のワンクリック認証
- セキュアなワンタイムトークン生成
- メール送信からログインまでの完全フロー
- JWT連携でのセッション管理
目次
マジックリンク認証の仕組みとNestJSでの実装メリット
マジックリンク認証の基本的な流れとNestJSでの実装メリットを理解しましょう。
Documentation | NestJS - A progr...


Documentation | NestJS - A progressive Node.js framework
Nest is a framework for building efficient, scalable Node.js server-side applications. It uses progressive JavaScript, is built with TypeScript and combines ele...
マジックリンク認証フロー
| ステップ | 動作 | セキュリティポイント |
|---|---|---|
| 1. メール入力 | ユーザーがメールアドレスを入力 | メール形式バリデーション |
| 2. トークン生成 | ワンタイムトークンを生成 | 有効期限とランダム性 |
| 3. メール送信 | 認証リンクをメールで送信 | HTTPS必須 |
| 4. リンククリック | ユーザーがリンクをクリック | トークン検証 |
| 5. 認証完了 | JWTトークンでセッション開始 | トークン無効化 |
従来認証との比較
| 項目 | パスワード認証 | マジックリンク認証 |
|---|---|---|
| ユーザビリティ | パスワード記憶が必要 | ✅ メールアクセスのみ |
| セキュリティ | パスワード漏洩リスク | ✅ ワンタイムトークン |
| 実装コスト | パスワードリセット機能必須 | ✅ シンプルな実装 |
| デメイット | – | メール依存、オフライン不可 |
必要なパッケージとプロジェクトセットアップ
マジックリンク認証に必要なパッケージをインストールしてプロジェクトを設定します。
パッケージインストール
# 認証関連パッケージ
npm install @nestjs/passport passport passport-magic-link
npm install @nestjs/jwt passport-jwt
# メール送信
npm install nodemailer
npm install @types/nodemailer -D
# 設定管理
npm install @nestjs/config
# バリデーション
npm install class-validator class-transformer
環境変数設定
# .env
# JWT設定
JWT_SECRET=your-super-secret-jwt-key
JWT_EXPIRATION=3600
# マジックリンク設定
MAGIC_LINK_SECRET=your-magic-link-secret
MAGIC_LINK_CALLBACK_URL=http://localhost:3000/auth/magic-link/callback
# メール設定(Gmail例)
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=your-email@gmail.com
SMTP_PASS=your-app-password
passport-magic-linkでの認証戦略実装
NestJSでPassport戦略を使ったマジックリンク認証を実装します。
MagicLinkStrategy実装
// auth/strategies/magic-link.strategy.ts
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-magic-link';
import { AuthService } from '../auth.service';
import { MailService } from '../mail.service';
@Injectable()
export class MagicLinkStrategy extends PassportStrategy(Strategy, 'magic-link') {
constructor(
private configService: ConfigService,
private authService: AuthService,
private mailService: MailService,
) {
super({
secret: configService.get('MAGIC_LINK_SECRET'),
userFields: ['email'],
tokenField: 'token',
callbackUrl: configService.get('MAGIC_LINK_CALLBACK_URL'),
sendMagicLink: async (destination: string, href: string) => {
// メール送信処理
await this.mailService.sendMagicLink(destination, href);
},
verify: async (payload: any) => {
// ユーザー検証・作成処理
return await this.authService.validateOrCreateUser(payload.destination);
},
});
}
}
AuthService実装
// auth/auth.service.ts
import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './entities/user.entity';
@Injectable()
export class AuthService {
constructor(
@InjectRepository(User)
private userRepository: Repository<User>,
private jwtService: JwtService,
) {}
async validateOrCreateUser(email: string): Promise<User> {
// 既存ユーザーを検索
let user = await this.userRepository.findOne({ where: { email } });
// 新規ユーザーの場合は作成
if (!user) {
user = this.userRepository.create({
email,
isEmailVerified: true, // マジックリンクでメール確認済み
createdAt: new Date(),
});
await this.userRepository.save(user);
}
return user;
}
async generateJwtToken(user: User): Promise<string> {
const payload = {
sub: user.id,
email: user.email,
isEmailVerified: user.isEmailVerified
};
return this.jwtService.sign(payload);
}
async login(user: User) {
const token = await this.generateJwtToken(user);
return {
access_token: token,
user: {
id: user.id,
email: user.email,
isEmailVerified: user.isEmailVerified,
},
};
}
}
Nodemailerでのメール送信設定
セキュアで信頼性の高いメール送信機能を実装します。
MailService実装
// auth/mail.service.ts
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as nodemailer from 'nodemailer';
@Injectable()
export class MailService {
private transporter;
constructor(private configService: ConfigService) {
// SMTP設定
this.transporter = nodemailer.createTransporter({
host: this.configService.get('SMTP_HOST'),
port: this.configService.get('SMTP_PORT'),
secure: this.configService.get('SMTP_SECURE') === 'true',
auth: {
user: this.configService.get('SMTP_USER'),
pass: this.configService.get('SMTP_PASS'),
},
});
}
async sendMagicLink(email: string, magicLink: string): Promise<void> {
const mailOptions = {
from: `"MyApp" <${this.configService.get('SMTP_USER')}>`,
to: email,
subject: '🔗 ログイン用マジックリンク',
html: this.generateMagicLinkEmail(magicLink),
};
try {
await this.transporter.sendMail(mailOptions);
console.log(`Magic link sent to: ${email}`);
} catch (error) {
console.error('Failed to send magic link:', error);
throw new Error('メール送信に失敗しました');
}
}
private generateMagicLinkEmail(magicLink: string): string {
return `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #333;">ログイン用マジックリンク</h2>
<p>以下のボタンをクリックしてログインしてください:</p>
<div style="text-align: center; margin: 30px 0;">
<a href="${magicLink}"
style="background-color: #007bff; color: white; padding: 12px 24px;
text-decoration: none; border-radius: 6px; display: inline-block;">
🔗 ログインする
</a>
</div>
<p style="color: #666; font-size: 14px;">
このリンクは10分間有効です。<br>
もしこのメールに心当たりがない場合は、無視してください。
</p>
<hr style="margin: 30px 0; border: none; border-top: 1px solid #eee;">
<p style="color: #999; font-size: 12px;">
リンクが機能しない場合は、以下のURLをコピーしてブラウザに貼り付けてください:<br>
<code style="background: #f5f5f5; padding: 4px; word-break: break-all;">${magicLink}</code>
</p>
</div>
`;
}
}
セキュリティ対策:有効期限とワンタイム使用
セキュアなマジックリンク認証のための重要な対策を実装します。
AuthController実装
// auth/auth.controller.ts
import { Controller, Post, Get, Body, Req, Res, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Request, Response } from 'express';
import { AuthService } from './auth.service';
import { RequestMagicLinkDto } from './dto/request-magic-link.dto';
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}
@Post('magic-link/request')
@UseGuards(AuthGuard('magic-link'))
async requestMagicLink(
@Body() requestMagicLinkDto: RequestMagicLinkDto,
@Res() res: Response,
) {
// passport-magic-linkがメール送信を処理
return res.json({
message: 'マジックリンクを送信しました。メールをご確認ください。',
success: true,
});
}
@Get('magic-link/callback')
@UseGuards(AuthGuard('magic-link'))
async magicLinkCallback(@Req() req: Request, @Res() res: Response) {
// 認証成功後の処理
const user = req.user as any;
const loginResult = await this.authService.login(user);
// JWTをクッキーに設定(セキュア)
res.cookie('access_token', loginResult.access_token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 3600000, // 1時間
});
// フロントエンドにリダイレクト
return res.redirect(`${process.env.FRONTEND_URL}/dashboard`);
}
@Post('logout')
async logout(@Res() res: Response) {
res.clearCookie('access_token');
return res.json({ message: 'ログアウトしました', success: true });
}
}
バリデーションDTO
// auth/dto/request-magic-link.dto.ts
import { IsEmail, IsNotEmpty } from 'class-validator';
export class RequestMagicLinkDto {
@IsEmail({}, { message: '有効なメールアドレスを入力してください' })
@IsNotEmpty({ message: 'メールアドレスは必須です' })
email: string;
}
セキュリティ強化ポイント
| 対策 | 実装方法 | 効果 |
|---|---|---|
| 短い有効期限 | 10分でトークン無効化 | トークン漏洩リスク軽減 |
| ワンタイム使用 | 使用後即座に無効化 | リプレイ攻撃防止 |
| HTTPS必須 | 本番環境での強制設定 | 通信内容暗号化 |
| レート制限 | 1分間に3回まで | スパム攻撃防止 |
| ログ記録 | 認証試行の詳細記録 | 不正アクセス検知 |
実際の使用例とエンドポイントテスト
実装したマジックリンク認証をテストして動作確認を行います。
AuthModule設定
// auth/auth.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { PassportModule } from '@nestjs/passport';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { MailService } from './mail.service';
import { MagicLinkStrategy } from './strategies/magic-link.strategy';
import { JwtStrategy } from './strategies/jwt.strategy';
import { User } from './entities/user.entity';
@Module({
imports: [
TypeOrmModule.forFeature([User]),
PassportModule,
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => ({
secret: configService.get('JWT_SECRET'),
signOptions: {
expiresIn: configService.get('JWT_EXPIRATION') + 's',
},
}),
inject: [ConfigService],
}),
],
controllers: [AuthController],
providers: [AuthService, MailService, MagicLinkStrategy, JwtStrategy],
exports: [AuthService],
})
export class AuthModule {}
API使用例
# 1. マジックリンク送信リクエスト
curl -X POST http://localhost:3000/auth/magic-link/request \
-H "Content-Type: application/json" \
-d '{"email": "user@example.com"}'
# レスポンス例
{
"message": "マジックリンクを送信しました。メールをご確認ください。",
"success": true
}
# 2. 保護されたルートのテスト(JWT必須)
curl -X GET http://localhost:3000/profile \
-H "Authorization: Bearer YOUR_JWT_TOKEN"
フロントエンド統合例
// React/Vue.jsでの使用例
const requestMagicLink = async (email) => {
try {
const response = await fetch('/auth/magic-link/request', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email }),
});
const data = await response.json();
if (data.success) {
alert('メールをご確認ください!');
}
} catch (error) {
console.error('マジックリンク送信エラー:', error);
}
};
完成!セキュアなパスワードレス認証システム
これでNestJSを使ったマジックリンク認証システムが完成しました。
🎉 実装できたこと
- パスワードレス認証:メールアドレスのみでログイン
- セキュアなトークン:ワンタイム・短期間有効
- ユーザーフレンドリー:パスワード記憶不要
- 拡張性:JWT連携で他システムとの統合容易
さらなる改善案
- Redis連携:大規模アプリでのトークン管理
- 2FA対応:SMS認証との組み合わせ
- Analytics連携:ログイン成功率の分析
- A/Bテスト:メールテンプレートの最適化
重要なリンク集
マジックリンク認証で、ユーザーにとってもシステムにとってもセキュアで快適な認証体験を提供しましょう。パスワード管理の煩雑さから解放された、モダンな認証システムの完成です!
