
By Schero
The most advanced passwordless authentication plugin for Strapi v5 Secure, modern, and user-friendly authentication using email-based magic links, OTP codes, and TOTP authenticators. Built for production with enterprise-grade security features.
released May 3, 2026
npm install strapi-plugin-magic-link-v5The most advanced passwordless authentication plugin for Strapi v5
Secure, modern, and user-friendly authentication using email-based magic links, OTP codes, and TOTP authenticators. Built for production with enterprise-grade security features.
The admin interface is available in 5 languages for international accessibility:
Users can switch languages in Settings β Magic Link β Interface Language.
MIT-licensed. Free for personal and commercial use. See LICENSE.
The admin panel offers a "License" tab where you can register an optional license key. This is purely cosmetic β it does not unlock any feature, and the plugin is fully usable without it. Registering a key just lets the project track installs and helps us prioritise issues.
You can ignore the License tab entirely; nothing will be gated.
All modes are available in every install β pick whichever fits your security profile:
| Mode | Description |
|---|---|
| Magic Link Only | One-click email login β fast & user-friendly |
| Magic Link via WhatsApp | Send magic links via the WhatsApp messenger |
| Magic Link + Email OTP | 6-digit code sent after the magic link click |
| Magic Link + TOTP (MFA) | Authenticator app (Google Authenticator, Authy, β¦) |
| TOTP-Only Login | Direct login with email + TOTP code |
Professional interface for managing magic link tokens with real-time statistics.

Simple modal to create tokens with custom TTL and context data.

Monitor and manage all active JWT sessions across your application.

Security feature to block suspicious IP addresses.

Comprehensive settings panel with modern UI.

Configure core functionality and authentication options.

Magic Link now supports sending authentication links via WhatsApp - completely FREE!
When a user requests a magic link and has a phone number on file, you can send the magic link via WhatsApp instead of email:
// Send magic link via WhatsApp
await fetch('/api/magic-link/send-link', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: 'user@example.com',
channel: 'whatsapp', // Send via WhatsApp
phoneNumber: '+491234567890' // Required for WhatsApp
})
});Configure your WhatsApp message templates in Settings -> Email & Messaging:
Available Variables:
<%= URL %> - Your frontend login URL<%= CODE %> - The magic link token<%= APP_NAME %> - Your app name<%= EXPIRY_TEXT %> - Token expiration textExample Template:
*<%= APP_NAME %> Login*
Click the link below to log in:
<%= URL %>?loginToken=<%= CODE %>
This link expires in <%= EXPIRY_TEXT %>.
_If you didn't request this, please ignore this message._Configure where users land when clicking the magic link:
https://yourapp.com/auth/login)If you have both Magic-Link and Magic-Mail plugins installed:
This plugin requires a configured email provider to send magic link emails.
Option 1: Nodemailer (Recommended)
Install the Strapi email plugin:
npm install @strapi/provider-email-nodemailerConfigure in config/plugins.js:
module.exports = ({ env }) => ({
email: {
config: {
provider: 'nodemailer',
providerOptions: {
host: env('SMTP_HOST', 'smtp.gmail.com'),
port: env('SMTP_PORT', 587),
auth: {
user: env('SMTP_USERNAME'),
pass: env('SMTP_PASSWORD'),
},
},
settings: {
defaultFrom: env('SMTP_DEFAULT_FROM', 'noreply@example.com'),
defaultReplyTo: env('SMTP_DEFAULT_REPLY_TO', 'support@example.com'),
},
},
},
});Option 2: Other Email Providers
You can use any Strapi-compatible email provider:
See Strapi Email Documentation for details.
This plugin is fully compatible with Strapi Email Designer 5!
# Install Email Designer 5
npm install strapi-plugin-email-designer-5Once installed, you can:
magicLink, token, user, expiresAt# Using npm
npm install strapi-plugin-magic-link-v5
# Using yarn
yarn add strapi-plugin-magic-link-v5
# Using pnpm
pnpm add strapi-plugin-magic-link-v5After installation, restart your Strapi server. The plugin will appear in your admin panel.
After installation, you'll see a license activation modal on first visit.
Enter your details to activate the plugin (completely free):
Email Address: your-email@example.com
First Name: John
Last Name: DoeClick "Create License" and you're done! The plugin will:
Important: This is a free activation - not a payment. It helps us track installations, provide support, and ensure security. You can also use an existing license key if you already have one.
Navigate to: Settings β Magic Link β Settings
Essential Configuration:
{
// Core Settings
"enabled": true,
"createUserIfNotExists": true, // Auto-create users on first login
"expire_period": 3600, // Token valid for 1 hour (3600s)
"token_length": 20, // Token security level (20-40 chars)
// Email Configuration
"from_email": "noreply@yourdomain.com",
"from_name": "Your App Name",
"object": "Your Magic Link Login",
"confirmationUrl": "https://yourdomain.com/auth/callback",
// Rate Limiting
"rate_limit_enabled": true,
"rate_limit_max_attempts": 5, // 5 requests per window
"rate_limit_window_minutes": 15 // 15 minute window
}Choose Authentication Mode:
Step 1: Request a magic link
const response = await fetch('/api/magic-link/send-link', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: 'user@example.com',
context: { redirectTo: '/dashboard' }
})
});
const data = await response.json();
console.log('Magic link sent!', data);Step 2: Verify token on callback page
const urlParams = new URLSearchParams(window.location.search);
const loginToken = urlParams.get('loginToken');
if (loginToken) {
try {
const response = await fetch(`/api/magic-link/login?loginToken=${loginToken}`);
const { jwt, user } = await response.json();
// Store JWT for authenticated requests
localStorage.setItem('token', jwt);
localStorage.setItem('user', JSON.stringify(user));
// Redirect to dashboard
window.location.href = '/dashboard';
} catch (error) {
console.error('Login failed:', error);
alert('Invalid or expired magic link');
}
}Step 1: Request magic link (same as above)
Step 2: User clicks magic link β redirected to OTP page
Step 3: Verify OTP code
async function verifyOTP(email, otpCode) {
const response = await fetch('/api/magic-link/verify-otp', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: email,
otp: otpCode
})
});
if (response.ok) {
const { jwt, user } = await response.json();
localStorage.setItem('token', jwt);
localStorage.setItem('user', JSON.stringify(user));
window.location.href = '/dashboard';
} else {
alert('Invalid OTP code. Please try again.');
}
}
// Usage
const otpInput = document.getElementById('otp-input');
verifyOTP('user@example.com', otpInput.value);Step 1: Setup TOTP for user (requires authentication)
async function setupTOTP() {
const token = localStorage.getItem('token');
const response = await fetch('/api/magic-link/totp/setup', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
const { secret, qrCode, otpauthUrl } = await response.json();
// Display QR code to user
document.getElementById('qr-code').innerHTML = `<img src="${qrCode}" alt="Scan with authenticator app" />`;
// Show secret for manual entry
document.getElementById('secret').textContent = secret;
return secret;
}Step 2: Verify TOTP code
async function verifyTOTP(totpCode) {
const token = localStorage.getItem('token');
const response = await fetch('/api/magic-link/totp/verify', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
token: totpCode
})
});
if (response.ok) {
alert('TOTP successfully enabled!');
return true;
} else {
alert('Invalid code. Please try again.');
return false;
}
}Step 3: Login with Email + TOTP (Alternative Login)
async function loginWithTOTP(email, totpCode) {
const response = await fetch('/api/magic-link/totp/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: email,
token: totpCode
})
});
if (response.ok) {
const { jwt, user } = await response.json();
localStorage.setItem('token', jwt);
localStorage.setItem('user', JSON.stringify(user));
window.location.href = '/dashboard';
} else {
alert('Invalid credentials');
}
}async function checkLicenseTier() {
const adminToken = localStorage.getItem('adminToken');
const response = await fetch('/magic-link/license/status', {
headers: {
'Authorization': `Bearer ${adminToken}`
}
});
const { data } = await response.json();
// data.features.premium - Email OTP available
// data.features.advanced - TOTP MFA available
// data.features.enterprise - All features
return {
isPremium: data.features?.premium || false,
isAdvanced: data.features?.advanced || false,
isEnterprise: data.features?.enterprise || false
};
}
// Usage
const license = await checkLicenseTier();
if (license.isPremium) {
console.log('Email OTP is available!');
}| Method | Endpoint | Description |
|---|---|---|
POST | /api/magic-link/send-link | Generate and send magic link to email |
GET | /api/magic-link/login?loginToken=xxx | Authenticate user with token |
POST | /api/magic-link/verify-otp | Verify Email OTP code |
POST | /api/magic-link/totp/login | Login with Email + TOTP code |
| Method | Endpoint | Description |
|---|---|---|
POST | /api/magic-link/totp/setup | Generate TOTP secret & QR code |
POST | /api/magic-link/totp/verify | Verify and enable TOTP |
POST | /api/magic-link/totp/disable | Disable TOTP for user |
GET | /api/magic-link/totp/status | Check if TOTP is configured |
| Method | Endpoint | Description |
|---|---|---|
GET | /magic-link/tokens | List all magic link tokens |
POST | /magic-link/tokens | Create a new token |
DELETE | /magic-link/tokens/:id | Permanently delete a token |
POST | /magic-link/tokens/:id/block | Block a token |
POST | /magic-link/tokens/:id/activate | Activate blocked token |
POST | /magic-link/tokens/:id/extend | Extend token validity |
| Method | Endpoint | Description |
|---|---|---|
GET | /magic-link/jwt-sessions | List active JWT sessions |
POST | /magic-link/revoke-jwt | Revoke a JWT session |
POST | /magic-link/ban-ip | Ban an IP address |
DELETE | /magic-link/ban-ip/:ip | Unban an IP address |
GET | /magic-link/banned-ips | List all banned IPs |
GET | /magic-link/rate-limit/stats | Get rate limit statistics |
POST | /magic-link/rate-limit/cleanup | Cleanup expired entries |
| Method | Endpoint | Description |
|---|---|---|
GET | /magic-link/otp-codes | List all OTP codes |
POST | /magic-link/otp-codes/cleanup | Cleanup expired OTP codes |
| Method | Endpoint | Description |
|---|---|---|
GET | /magic-link/license/status | Get current license status |
POST | /magic-link/license/auto-create | Create license with admin data |
POST | /magic-link/license/activate | Activate license with key |
Magic Link provides compatibility modes for seamless migration from other authentication plugins. No frontend code changes required!
If you're migrating from strapi-plugin-passwordless, enable the compatibility mode:
Enable in Admin Panel: Settings β Magic Link β Compatibility β "Passwordless Plugin Compatibility"
When enabled, these additional routes become available:
| Original Route | Magic Link Handler | Description |
|---|---|---|
POST /api/passwordless/send-link | auth.sendLink | Send magic link email |
GET /api/passwordless/login?loginToken=xxx | auth.login | Authenticate with token |
Request/Response Format: Identical to the original plugin!
// Your existing code works without changes:
const response = await fetch('/api/passwordless/send-link', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: 'user@example.com',
username: 'John Doe', // Optional: username for new users
context: { redirectTo: '/dashboard' }
})
});
// Login response is identical:
// { jwt: "...", user: {...}, context: {...} }Migration Steps:
strapi-plugin-magic-link-v5strapi-plugin-passwordless from package.jsonGradual Migration: You can later switch your frontend to use /api/magic-link/* endpoints and disable compatibility mode.
Magic Link is fully compatible with strapi-plugin-email-designer-5!
Enable in Admin Panel: Settings β Magic Link β Compatibility β "Email Designer 5 Integration"
# Install Email Designer 5
npm install strapi-plugin-email-designer-5Template Variables:
| Variable | Description |
|---|---|
{{magicLink}} | Complete magic link URL with token |
{{token}} | Raw token value |
{{user.email}} | User's email address |
{{user.username}} | User's username |
{{expiresAt}} | Token expiration time |
Configuration:
Compatibility routes are disabled by default for security. When disabled, accessing /api/passwordless/* returns a 404 Not Found response - the routes appear to not exist.
Configure in Admin Panel β Settings β MFA & Login Modes
{
// Authentication Modes (mutually exclusive)
"otp_enabled": false, // Enable Email OTP (Premium)
"mfa_require_totp": false, // Enable TOTP MFA (Advanced)
"totp_as_primary_auth": false, // Enable TOTP-only login (Advanced)
// Email OTP Configuration (Premium)
"otp_type": "email", // "email" | "sms" | "totp"
"otp_length": 6, // Code length (4-8 digits)
"otp_expiry": 300, // Validity in seconds (default: 5 min)
"otp_max_attempts": 3, // Max verification attempts
"otp_resend_cooldown": 60, // Cooldown before resend (seconds)
// TOTP Configuration (Advanced)
"totp_issuer": "Magic Link", // Displayed in authenticator app
"totp_digits": 6, // Code digits (6 or 8)
"totp_period": 30, // Time period (seconds)
"totp_algorithm": "sha1" // "sha1" | "sha256" | "sha512"
}{
"enabled": true, // Enable/disable plugin
"createUserIfNotExists": true, // Auto-create users on first login
"expire_period": 3600, // Token expiration (seconds)
"token_length": 20, // Token security level (20-40)
"stays_valid": false, // Token reusable after first use
"max_login_attempts": 5, // Limit failed login attempts
"callback_url": "https://yourdomain.com/auth/callback"
}{
"from_name": "Your App",
"from_email": "noreply@yourdomain.com",
"response_email": "support@yourdomain.com",
"object": "Your Magic Link Login",
"message_html": "<h1>Welcome!</h1><p>Click to login: <a href='<%= URL %>?loginToken=<%= CODE %>'>Login</a></p>",
"message_text": "Your login link: <%= URL %>?loginToken=<%= CODE %>"
}Template Variables:
<%= URL %> - Your confirmation URL<%= CODE %> - The generated token<%= USER %> - User email/username<%= EXPIRES_AT %> - Token expiration time{
"use_jwt_token": true, // Use JWT for authentication
"jwt_token_expires_in": "30d", // JWT validity (e.g., '7d', '30d', '365d')
"store_login_info": true // Track IP and user agent
}{
"rate_limit_enabled": true, // Enable rate limiting
"rate_limit_max_attempts": 5, // Max requests per window
"rate_limit_window_minutes": 15 // Time window in minutes
}How it works:
429 Too Many Requests when exceededExample: With default settings (5 attempts per 15 minutes):
The plugin stores secrets that must be protected across restarts and should not be rotated with the rest of your Strapi key material. Two env vars are mandatory in production (the plugin refuses to boot without them):
# 32+ random bytes β used as AES-256 key for TOTP secrets.
# DO NOT ROTATE: existing TOTP secrets would become undecryptable.
MAGIC_LINK_ENCRYPTION_KEY=<hex from: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))">
# 16+ random bytes β pepper mixed into OTP code hashes to defeat
# rainbow-table attacks on stored 6-digit codes if the DB is ever leaked.
MAGIC_LINK_OTP_PEPPER=<hex from: node -e "console.log(require('crypto').randomBytes(24).toString('hex'))">In non-production environments (NODE_ENV !== 'production') the plugin
falls back to derived values so local dev setups keep working β but a WARN
is logged once per process.
Rate limits and all audit logs identify the caller by ctx.request.ip,
which Koa derives from the TCP connection by default. If your Strapi is
behind a reverse proxy (nginx, Caddy, Cloudflare, ALB, β¦) you MUST:
Enable proxy trust in config/server.{js,ts}:
module.exports = ({ env }) => ({
proxy: true, // Trust X-Forwarded-For from the proxy
// ... rest of config
});Configure the proxy to strip any incoming X-Forwarded-For and
emit only its own value. Without this, an attacker can spoof the
header, defeating per-IP rate limits.
If you have multiple proxy hops, pin trust to the outermost ones via
Koa's app.proxyIpHeader / app.maxIpsCount (see Koa docs).
A misconfigured deployment will either:
Starting with this release, /otp/send, /otp/verify and /otp/resend
require the client to present the magicLinkToken they received from
/magic-link/login when it returned { requiresOTP: true }. This prevents
/otp/send from being abused as a standalone email-bombing vector against
arbitrary addresses and prevents /otp/verify from being a first-factor
login on its own.
If you have legacy integrations that must keep the old behaviour, disable
it via settings: otp_strict_binding: false. Doing so emits a WARN on
every request and is not recommended.
Customize your magic link emails using template variables:
<!-- HTML Template -->
<h1>Welcome!</h1>
<p>Click to login:</p>
<a href="<%= URL %>?loginToken=<%= CODE %>">
Login to Your Account
</a>Available Variables:
<%= URL %> - Your confirmation URL<%= CODE %> - The generated token| Issue | Solution |
|---|---|
| Emails not sending | Check Strapi email provider configuration in config/plugins.js |
| Token invalid errors | Verify token hasn't expired. Check expire_period setting |
| User not found | Enable createUserIfNotExists setting in admin panel |
| License activation fails | Check network connectivity. Try manual activation with license key |
| npm install fails | Use npm install --legacy-peer-deps or update to latest npm |
| OTP settings not visible | Check license tier. Email OTP requires Premium license |
| TOTP not working | Verify Advanced license is active. Check authenticator app time sync |
| "Current License: Free" but Premium activated | Refresh browser cache. License info loads from /magic-link/license/status |
| Lock icons not centered | Update to v4.16.1+. Mobile UI fixes are included |
| Email OTP shown without license | Update to v4.16.3+. License validation is fixed |
Enable debug logging in your Strapi instance:
# In development
NODE_ENV=development npm run develop
# Check logs in terminal for Magic Link debug outputIf you need to reset the plugin:
# 1. Stop Strapi
# 2. Clear plugin database entries
# 3. Restart Strapi
npm run developContributions are welcome! Please:
git checkout -b feature/amazing-feature)git commit -m 'feat: add amazing feature')git push origin feature/amazing-feature)Commit Convention: Follow Conventional Commits
feat: - New featuresfix: - Bug fixesdocs: - Documentation changeschore: - Maintenance tasksWhatsApp Integration
New Settings:
frontend_login_url - Custom frontend URL for magic linkswhatsapp_message_text - WhatsApp message templatewhatsapp_app_name - App name displayed in WhatsApp messageswhatsapp_debug - Enable/disable verbose WhatsApp loggingPlugin Compatibility Mode
strapi-plugin-passwordless
/api/passwordless/send-link and /api/passwordless/login routescompatibility-check policy for secure route accessLicense Validation Fix
premium, advanced, enterprise from license responsetier field for UI compatibilityActive State & Auto-Disable Fix
Lock Icons & License Checks
Major UX Improvements
Email OTP Configuration
Mobile & Settings UI Fixes
For full version history, see GitHub Releases.
This project is licensed under the MIT License - see the LICENSE file for details.
Important: While the code is open source, the license validation system must remain intact. This ensures quality, security, and continued development of the plugin.
If this plugin saves you time and you'd like to support development:
This plugin ships with a first-party dependency tree audited on every
release, but two transitive paths are governed by upstream packages we
do not control. If your own npm audit flags any of the following,
add the matching entry to your Strapi project's package.json
(not this plugin's β npm overrides only take effect on the
root project):
{
"overrides": {
// GHSA-xq3m-2v4x-88gg β protobufjs <7.5.5 arbitrary code execution.
// Reaches us via @whiskeysockets/baileys β libsignal β protobufjs.
// We already ship this override for local development; apply it in
// your Strapi project so the fix propagates to the installed tree.
"protobufjs": "^7.5.5",
// Optional: if you do not use the WhatsApp delivery feature, you
// can also skip installing @whiskeysockets/baileys altogether:
// npm install --omit=optional
// (The Magic-Link code path detects the missing module and
// disables the WhatsApp controller at runtime β the rest of the
// plugin keeps working.)
}
}After editing package.json, run:
rm -rf node_modules package-lock.json
npm install
npm auditThe critical protobufjs advisory disappears once the override is in
place. The remaining @strapi/design-system β lodash advisory is a
Strapi-core issue and will clear up automatically when you upgrade
@strapi/strapi to the latest patch release.
Share your work with the community and get it listed in the Strapi ecosystem for everyone to discover and use.
Submit
