strapi-plugin-magic-link-v5 5.5.8 β†’ 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/CHANGELOG.md +740 -0
  2. package/README.md +662 -928
  3. package/dist/admin/LicenseGuard-BgLA9go3.mjs +495 -0
  4. package/dist/admin/LicenseGuard-ewCjpc17.js +513 -0
  5. package/dist/admin/de-B1FNErPH.mjs +673 -0
  6. package/dist/admin/de-e75OECdq.js +673 -0
  7. package/dist/admin/en-CABKGbxH.js +673 -0
  8. package/dist/admin/en-HqDOFBCc.mjs +673 -0
  9. package/dist/admin/es-DpWl0Fax.js +673 -0
  10. package/dist/admin/es-jb94jqPR.mjs +673 -0
  11. package/dist/admin/fr-BQhLPvYw.js +673 -0
  12. package/dist/admin/fr-BseH1wrr.mjs +673 -0
  13. package/dist/admin/index-6ot9j1cN.js +5268 -0
  14. package/dist/admin/index-B390rccl.js +622 -0
  15. package/dist/admin/index-BPGKtruf.mjs +5266 -0
  16. package/dist/admin/index-BaTIQtnA.js +3869 -0
  17. package/dist/admin/index-CmG-93yx.js +141 -0
  18. package/dist/admin/index-DXe_ge4v.mjs +620 -0
  19. package/dist/admin/index-Dv5PVT1E.mjs +3867 -0
  20. package/dist/admin/index-k240ls6p.mjs +142 -0
  21. package/dist/admin/index.js +3 -3
  22. package/dist/admin/index.mjs +1 -2
  23. package/dist/admin/pt-CPkaveti.js +673 -0
  24. package/dist/admin/pt-DhlSCJn2.mjs +673 -0
  25. package/dist/admin/useUnsavedChangesGuard-BVZ4Mg4E.js +225 -0
  26. package/dist/admin/useUnsavedChangesGuard-QnkiZTLP.mjs +224 -0
  27. package/dist/server/i18n/de.json +32 -1
  28. package/dist/server/i18n/en.json +32 -1
  29. package/dist/server/i18n/es.json +32 -1
  30. package/dist/server/i18n/fr.json +32 -1
  31. package/dist/server/i18n/pt.json +32 -1
  32. package/dist/server/index.js +7092 -5979
  33. package/dist/server/index.mjs +7114 -5979
  34. package/package.json +42 -41
  35. package/strapi-admin.js +3 -1
  36. package/strapi-server.js +3 -2
  37. package/dist/_chunks/LicenseGuard-CiyjFnrw.js +0 -54
  38. package/dist/_chunks/LicenseGuard-DJ5fq2UZ.mjs +0 -55
  39. package/dist/_chunks/de-BSOk5suQ.js +0 -454
  40. package/dist/_chunks/de-CwhYs3O3.mjs +0 -454
  41. package/dist/_chunks/en-DnvSpXvu.js +0 -454
  42. package/dist/_chunks/en-gXXgGDV1.mjs +0 -454
  43. package/dist/_chunks/es-B-tnwByt.js +0 -407
  44. package/dist/_chunks/es-DG5gW3RZ.mjs +0 -407
  45. package/dist/_chunks/fr-BKo8t7Fx.js +0 -407
  46. package/dist/_chunks/fr-BQn1OHOR.mjs +0 -407
  47. package/dist/_chunks/index-01GHNxSr.js +0 -307
  48. package/dist/_chunks/index-B9Ch8Jpe.mjs +0 -5066
  49. package/dist/_chunks/index-Bj-erY7g.mjs +0 -143
  50. package/dist/_chunks/index-CGxbhJfg.js +0 -142
  51. package/dist/_chunks/index-DF3Edydl.mjs +0 -330
  52. package/dist/_chunks/index-Dufv-daf.mjs +0 -305
  53. package/dist/_chunks/index-I72rBTkL.js +0 -332
  54. package/dist/_chunks/index-SCxv154G.mjs +0 -2904
  55. package/dist/_chunks/index-XYWLmmEs.js +0 -5068
  56. package/dist/_chunks/index-zzoovbM0.js +0 -2906
  57. package/dist/_chunks/pt-CjEkXBAz.js +0 -407
  58. package/dist/_chunks/pt-trziAOzT.mjs +0 -407
package/README.md CHANGED
@@ -1,1109 +1,843 @@
1
- # πŸ” Magic Link - Passwordless Authentication for Strapi v5
1
+ # Magic Link for Strapi 5
2
2
 
3
- **The most advanced passwordless authentication plugin for Strapi v5**
4
-
5
- Secure, modern, and user-friendly authentication using email-based magic links, OTP codes, and TOTP authenticators. Built for production with enterprise-grade security features.
3
+ Passwordless login for Strapi 5. Your users type their e-mail address, click the link in their inbox and get a normal Users & Permissions session, the same kind `/api/auth/local` creates. When you need more than a link, add a second factor: an e-mailed one-time code (OTP) or an authenticator app (TOTP) with backup codes.
6
4
 
5
+ [![npm version](https://img.shields.io/npm/v/strapi-plugin-magic-link-v5.svg)](https://www.npmjs.com/package/strapi-plugin-magic-link-v5)
7
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
8
- [![npm version](https://badge.fury.io/js/strapi-plugin-magic-link-v5.svg)](https://www.npmjs.com/package/strapi-plugin-magic-link-v5)
9
- [![Strapi v5](https://img.shields.io/badge/Strapi-v5-7C3AED.svg)](https://strapi.io)
10
-
11
- ## Why Magic Link?
12
-
13
- - **Zero Setup Time** - Install, configure SMTP, and start using.
14
- - **Production Ready** - Battle-tested with rate limiting, IP bans, and session management.
15
- - **Multi-Factor Authentication** - Email OTP + TOTP Authenticator support, included.
16
- - **WhatsApp Integration** - Send magic links via WhatsApp messenger.
17
- - **Beautiful UI** - Modern, responsive admin interface localized in 5 languages.
18
- - **Free & Open** - MIT license, no paywall, every feature available out of the box.
19
-
20
- ## πŸ“š Quick Links
21
-
22
- - [✨ Features](#-features) - Authentication modes, security, license tiers
23
- - [πŸš€ Installation](#-installation) - Get started in minutes
24
- - [🎯 Quick Start](#-quick-start) - License activation & configuration
25
- - [πŸ’» Frontend Examples](#-frontend-implementation) - Magic Link, Email OTP, TOTP flows
26
- - [πŸ“‘ API Endpoints](#-api-endpoints) - Complete API reference
27
- - [πŸ”„ Plugin Compatibility](#-plugin-compatibility) - Migration from other plugins
28
- - [βš™οΈ Configuration](#-configuration) - All settings explained
29
- - [πŸ› Troubleshooting](#-troubleshooting) - Common issues & solutions
30
- - [πŸ“ Changelog](#-changelog) - Version history & updates
31
-
32
- ## 🌍 Supported Languages
33
-
34
- The admin interface is available in **5 languages** for international accessibility:
35
-
36
- - πŸ‡¬πŸ‡§ **English** - Global standard
37
- - πŸ‡©πŸ‡ͺ **Deutsch** - German (DACH region)
38
- - πŸ‡«πŸ‡· **FranΓ§ais** - French (Strapi's home & community)
39
- - πŸ‡ͺπŸ‡Έ **EspaΓ±ol** - Spanish (Spain & Latin America)
40
- - πŸ‡΅πŸ‡Ή **PortuguΓͺs** - Portuguese (Brazil & Portugal)
41
-
42
- Users can switch languages in **Settings β†’ Magic Link β†’ Interface Language**.
43
-
44
- ---
45
-
46
- ## πŸ“œ License
47
-
48
- MIT-licensed. Free for personal and commercial use. See [LICENSE](./LICENSE).
49
-
50
- ### Optional license-key activation
51
-
52
- The admin panel offers a "License" tab where you can register an optional
53
- license key. This is purely cosmetic β€” it does **not** unlock any
54
- feature, and the plugin is fully usable without it. Registering a key
55
- just lets the project track installs and helps us prioritise issues.
56
-
57
- You can ignore the License tab entirely; nothing will be gated.
58
-
59
- ---
60
-
61
- ## ✨ Features
62
-
63
- ### Authentication Modes
64
-
65
- All modes are available in every install β€” pick whichever fits your security profile:
66
-
67
- | Mode | Description |
68
- |------|-------------|
69
- | **Magic Link Only** | One-click email login β€” fast & user-friendly |
70
- | **Magic Link via WhatsApp** | Send magic links via the WhatsApp messenger |
71
- | **Magic Link + Email OTP** | 6-digit code sent after the magic link click |
72
- | **Magic Link + TOTP (MFA)** | Authenticator app (Google Authenticator, Authy, …) |
73
- | **TOTP-Only Login** | Direct login with email + TOTP code |
74
-
75
- ### πŸ›‘οΈ Security Features
76
-
77
- - **Rate Limiting** β€” 5 requests per 15 minutes (configurable)
78
- - **IP Banning** β€” Block suspicious addresses with one click
79
- - **Session Management** β€” Monitor and revoke active JWT sessions
80
- - **Login Tracking** β€” Store IP addresses and user agents
81
- - **Token Expiration** β€” Configurable validity periods
82
- - **Brute Force Protection** β€” Login attempt limiting
83
- - **Security Score** β€” Real-time configuration assessment
84
-
85
- ### 🎨 Admin Interface
86
-
87
- - **Modern Dashboard** - Beautiful token statistics and charts
88
- - **Mobile Optimized** - Perfect icon centering on all screen sizes
89
- - **German Translations** - Fully localized UI (5 languages total)
90
- - **License Management** - Visual tier display and feature unlocking
91
- - **Professional Token Management** - Create, extend, block, delete
92
- - **Search & Filter** - Find anything in seconds
93
- - **Bulk Operations** - Manage multiple items at once
94
-
95
- ### Customization
96
-
97
- - **Email Templates** - HTML & plain text with variables
98
- - **WhatsApp Templates** - Customizable WhatsApp message content
99
- - **Email Designer 5** - Visual email builder integration
100
- - **Flexible Configuration** - All settings in admin panel
101
- - **Frontend URL** - Configure custom frontend login URLs
102
- - **Custom Callbacks** - Post-login redirect URLs
103
- - **Auto User Creation** - Optional on first login
104
- - **Token Reusability** - One-time or multi-use tokens
105
-
106
- ---
107
-
108
- ## πŸ“Έ Screenshots
109
-
110
- ### Token Management Dashboard
111
- Professional interface for managing magic link tokens with real-time statistics.
112
-
113
- ![Token Dashboard](pics/token-dashboard.png)
114
-
115
- ### Create New Token
116
- Simple modal to create tokens with custom TTL and context data.
117
-
118
- ![Create Token](pics/createToken.png)
119
-
120
- ### JWT Session Management
121
- Monitor and manage all active JWT sessions across your application.
122
-
123
- ![JWT Sessions](pics/jwt-dashboard.png)
124
-
125
- ### IP Ban Management
126
- Security feature to block suspicious IP addresses.
127
-
128
- ![IP Bans](pics/ipban-dashboard.png)
129
-
130
- ### Settings Interface
131
- Comprehensive settings panel with modern UI.
132
-
133
- ![Settings Overview](pics/settings.png)
134
-
135
- ### General Settings
136
- Configure core functionality and authentication options.
137
-
138
- ![General Settings](pics/settings-general.png)
139
-
140
- ---
141
-
142
- ## WhatsApp Integration (NEW!)
143
-
144
- Magic Link now supports sending authentication links via **WhatsApp** - completely FREE!
145
-
146
- ### Why WhatsApp?
147
-
148
- - **Higher Open Rates** - 98% open rate vs 20% for email
149
- - **Instant Delivery** - No spam folders, no delays
150
- - **User Preference** - Many users prefer messaging over email
151
- - **FREE** - No additional costs, uses your own WhatsApp number
152
-
153
- ### Quick Setup
154
-
155
- 1. Navigate to **Admin Panel -> Magic Link -> WhatsApp**
156
- 2. Scan the QR code with your WhatsApp mobile app
157
- 3. Done! Your WhatsApp is connected
158
-
159
- ### How It Works
160
-
161
- 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:
162
-
163
- ```javascript
164
- // Send magic link via WhatsApp
165
- await fetch('/api/magic-link/send-link', {
166
- method: 'POST',
167
- headers: { 'Content-Type': 'application/json' },
168
- body: JSON.stringify({
169
- email: 'user@example.com',
170
- channel: 'whatsapp', // Send via WhatsApp
171
- phoneNumber: '+491234567890' // Required for WhatsApp
172
- })
173
- });
174
- ```
7
+ [![Strapi 5](https://img.shields.io/badge/Strapi-5.24%2B-4945FF.svg)](https://strapi.io)
8
+
9
+ **Project site:** [schero94.github.io/strapi-plugin-magic-link-v5](https://schero94.github.io/strapi-plugin-magic-link-v5/)
10
+
11
+ ![Magic Link tokens page in the Strapi admin panel](pics/tokens.png)
12
+
13
+ ## Contents
14
+
15
+ - [What you get](#what-you-get)
16
+ - [Screenshots](#screenshots)
17
+ - [Requirements](#requirements)
18
+ - [Quick start (5 minutes)](#quick-start-5-minutes)
19
+ - [Frontend integration](#frontend-integration)
20
+ - [Second factors: e-mail OTP, TOTP and backup codes](#second-factors-e-mail-otp-totp-and-backup-codes)
21
+ - [Refresh mode (users-permissions sessions)](#refresh-mode-users-permissions-sessions)
22
+ - [Public API](#public-api)
23
+ - [Errors](#errors)
24
+ - [Admin API](#admin-api)
25
+ - [Permissions](#permissions)
26
+ - [Configuration reference](#configuration-reference)
27
+ - [E-mail templates](#e-mail-templates)
28
+ - [Security model](#security-model)
29
+ - [Running behind a proxy or in a cluster](#running-behind-a-proxy-or-in-a-cluster)
30
+ - [Migrating from strapi-plugin-passwordless](#migrating-from-strapi-plugin-passwordless)
31
+ - [Troubleshooting](#troubleshooting)
32
+ - [Upgrading from 5.x to 6.0.0](#upgrading-from-5x-to-600)
33
+ - [Languages, license and support](#languages-license-and-support)
34
+
35
+ ## What you get
36
+
37
+ - **Magic links by e-mail.** One API call sends the link. Your landing page exchanges it for a JWT.
38
+ - **Optional second factors.**
39
+ - E-mail OTP after the link click.
40
+ - Authenticator apps (TOTP) as optional or required MFA: enrolment during sign-in, self-service for logged-in users, and one-time backup codes.
41
+ - TOTP-only login without an e-mail round trip.
42
+ - **Built for production.**
43
+ - Links are stored hashed, expire, and a single-use link is consumed atomically.
44
+ - Rate limits per IP and per address, IP bans, and an always-on limit for code checks.
45
+ - A JWT session list with revocation, also for users-permissions refresh sessions.
46
+ - **Works with your stack.** Users & Permissions (legacy JWTs and refresh-token sessions), the Strapi e-mail plugin, Magic Mail, Email Designer 5, and a `strapi-plugin-passwordless` compatibility mode.
47
+ - **Admin panel.**
48
+ - Pages for tokens, OTP codes, JWT sessions, IP bans, settings and license.
49
+ - Light and dark mode, translated into English, German, French, Spanish and Portuguese.
50
+ - Fine-grained admin permissions, enforced on the server.
51
+
52
+ Magic Link is **e-mail only**. WhatsApp and SMS delivery were removed in 6.0.0.
53
+
54
+ ## Screenshots
55
+
56
+ All screenshots use synthetic data (`example.test` addresses and documentation IP ranges).
57
+
58
+ | Tokens | Create a token |
59
+ | ------------------------------------------------------------- | -------------------------------------------------------- |
60
+ | ![Tokens list with status cards and filters](pics/tokens.png) | ![Create Magic Link Token dialog](pics/create-token.png) |
61
+
62
+ | JWT sessions | IP bans |
63
+ | ---------------------------------------------------------------------- | ----------------------------------------------------------- |
64
+ | ![JWT sessions with active and revoked entries](pics/jwt-sessions.png) | ![IP ban list with IPv4 and IPv6 entries](pics/ip-bans.png) |
65
+
66
+ | Settings: general | Settings: sign-in modes and MFA |
67
+ | ------------------------------------------------------ | -------------------------------------------------------- |
68
+ | ![General settings section](pics/settings-general.png) | ![Sign-in mode and MFA selection](pics/settings-mfa.png) |
69
+
70
+ | License | Dark mode |
71
+ | --------------------------------- | ------------------------------------------------- |
72
+ | ![License page](pics/license.png) | ![Tokens page in dark mode](pics/tokens-dark.png) |
73
+
74
+ On phones the tables turn into cards:
75
+
76
+ | Tokens on a phone | JWT sessions on a phone |
77
+ | ------------------------------------------------------------------ | -------------------------------------------------------------------------- |
78
+ | ![Tokens as cards on a 390 px wide screen](pics/mobile-tokens.png) | ![JWT sessions page on a 390 px wide screen](pics/mobile-jwt-sessions.png) |
79
+
80
+ ## Requirements
81
+
82
+ | Component | Version |
83
+ | ------------------- | ---------------------------------------------------------------------------------------- |
84
+ | Strapi | 5.24.0 or newer (tested on 5.61.0). Refresh mode with the httpOnly cookie needs 5.33.3+. |
85
+ | Node.js | 22 to 26 (the range Strapi 5.61 supports) |
86
+ | Users & Permissions | Installed (default in Strapi 5) |
87
+ | E-mail | A configured Strapi e-mail provider, or Magic Mail |
88
+ | Environment | `MAGIC_LINK_ENCRYPTION_KEY` and `MAGIC_LINK_OTP_PEPPER`, 32+ characters each (see below) |
89
+
90
+ ## Quick start (5 minutes)
91
+
92
+ ### 1. Install
175
93
 
176
- ### Customizable Messages
177
-
178
- Configure your WhatsApp message templates in **Settings -> Email & Messaging**:
179
-
180
- **Available Variables:**
181
- - `<%= URL %>` - Your frontend login URL
182
- - `<%= CODE %>` - The magic link token
183
- - `<%= APP_NAME %>` - Your app name
184
- - `<%= EXPIRY_TEXT %>` - Token expiration text
185
-
186
- **Example Template:**
94
+ ```bash
95
+ npm install strapi-plugin-magic-link-v5
187
96
  ```
188
- *<%= APP_NAME %> Login*
189
-
190
- Click the link below to log in:
191
97
 
192
- <%= URL %>?loginToken=<%= CODE %>
98
+ ### 2. Set the two required secrets
193
99
 
194
- This link expires in <%= EXPIRY_TEXT %>.
100
+ The plugin refuses to start without them, also in development:
195
101
 
196
- _If you didn't request this, please ignore this message._
102
+ ```bash
103
+ # .env, generate each value with:
104
+ # node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
105
+ MAGIC_LINK_ENCRYPTION_KEY=<64 hex characters>
106
+ MAGIC_LINK_OTP_PEPPER=<64 hex characters>
197
107
  ```
198
108
 
199
- ### Frontend Login URL
200
-
201
- Configure where users land when clicking the magic link:
202
-
203
- 1. Go to **Settings -> Email & Messaging**
204
- 2. Set **Frontend Login URL** (e.g., `https://yourapp.com/auth/login`)
205
- 3. Magic links will now redirect to your frontend
206
-
207
- ### Integration with Magic-Mail
208
-
209
- If you have both **Magic-Link** and **Magic-Mail** plugins installed:
210
-
211
- - Magic-Link delegates WhatsApp functionality to Magic-Mail
212
- - Only ONE WhatsApp connection needed (managed by Magic-Mail)
213
- - Navigate to Magic-Mail for WhatsApp setup
214
- - Unified WhatsApp management across both plugins
215
-
216
- ---
217
-
218
- ## Prerequisites
109
+ For a throwaway local setup you can set `MAGIC_LINK_ALLOW_DEV_SECRETS=true` instead. It is ignored when `NODE_ENV=production`.
219
110
 
220
- This plugin requires a **configured email provider** to send magic link emails.
111
+ ### 3. Configure e-mail
221
112
 
222
- ### Email Provider Setup
113
+ Any Strapi e-mail provider works. Example with Nodemailer in `config/plugins.js`:
223
114
 
224
- **Option 1: Nodemailer (Recommended)**
225
-
226
- Install the Strapi email plugin:
227
-
228
- ```bash
229
- npm install @strapi/provider-email-nodemailer
230
- ```
231
-
232
- Configure in `config/plugins.js`:
233
-
234
- ```javascript
115
+ ```js
235
116
  module.exports = ({ env }) => ({
236
117
  email: {
237
118
  config: {
238
119
  provider: 'nodemailer',
239
120
  providerOptions: {
240
- host: env('SMTP_HOST', 'smtp.gmail.com'),
241
- port: env('SMTP_PORT', 587),
242
- auth: {
243
- user: env('SMTP_USERNAME'),
244
- pass: env('SMTP_PASSWORD'),
245
- },
121
+ host: env('SMTP_HOST'),
122
+ port: env.int('SMTP_PORT', 587),
123
+ auth: { user: env('SMTP_USERNAME'), pass: env('SMTP_PASSWORD') },
246
124
  },
247
125
  settings: {
248
- defaultFrom: env('SMTP_DEFAULT_FROM', 'noreply@example.com'),
249
- defaultReplyTo: env('SMTP_DEFAULT_REPLY_TO', 'support@example.com'),
126
+ defaultFrom: 'login@example.com',
127
+ defaultReplyTo: 'support@example.com',
250
128
  },
251
129
  },
252
130
  },
253
131
  });
254
132
  ```
255
133
 
256
- **Option 2: Other Email Providers**
257
-
258
- You can use any Strapi-compatible email provider:
259
- - SendGrid
260
- - Mailgun
261
- - Amazon SES
262
- - Postmark
263
- - Any SMTP service
264
-
265
- See [Strapi Email Documentation](https://docs.strapi.io/dev-docs/plugins/email) for details.
266
-
267
- ### Email Designer 5 Integration (Optional)
268
-
269
- This plugin is **fully compatible** with [Strapi Email Designer 5](https://www.npmjs.com/package/strapi-plugin-email-designer-5)!
134
+ ### 4. Build, start and point links at your frontend
270
135
 
271
136
  ```bash
272
- # Install Email Designer 5
273
- npm install strapi-plugin-email-designer-5
137
+ npm run build && npm run develop
274
138
  ```
275
139
 
276
- Once installed, you can:
277
- - βœ… Create beautiful email templates in the visual designer
278
- - βœ… Use template variables: `magicLink`, `token`, `user`, `expiresAt`
279
- - βœ… Enable in Settings β†’ Magic Link β†’ Email Settings
140
+ In the admin panel, open **Settings > Magic Link > Settings > General**:
280
141
 
281
- ---
142
+ - **Frontend base URL**: `https://app.example.com`
143
+ - **Frontend login path**: `/auth/magic-link`
282
144
 
283
- ## πŸš€ Installation
284
-
285
- ```bash
286
- # Using npm
287
- npm install strapi-plugin-magic-link-v5
145
+ Links now look like `https://app.example.com/auth/magic-link?loginToken=...`. URLs must use `https`; `http` is accepted only for `localhost`.
288
146
 
289
- # Using yarn
290
- yarn add strapi-plugin-magic-link-v5
147
+ ### 5. Send your first link
291
148
 
292
- # Using pnpm
293
- pnpm add strapi-plugin-magic-link-v5
294
- ```
295
-
296
- After installation, **restart your Strapi server**. The plugin will appear in your admin panel.
297
-
298
- ---
299
-
300
- ## 🎯 Quick Start
301
-
302
- ### 1️⃣ First Time Setup - License Activation (Free)
303
-
304
- **After installation, you'll see a license activation modal on first visit.**
305
-
306
- Enter your details to activate the plugin (completely free):
307
-
308
- ```
309
- Email Address: your-email@example.com
310
- First Name: John
311
- Last Name: Doe
149
+ ```bash
150
+ curl -X POST http://localhost:1337/api/magic-link/send-link \
151
+ -H 'Content-Type: application/json' \
152
+ -d '{"email":"ada@example.com"}'
312
153
  ```
313
154
 
314
- Click **"Create License"** and you're done! The plugin will:
315
- - βœ… Automatically register your installation
316
- - βœ… Activate all features (no payment required)
317
- - βœ… Connect to the license validation system
318
-
319
- **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.
320
-
321
- ### 2️⃣ Configure Settings
322
-
323
- **Navigate to:** Settings β†’ Magic Link β†’ Settings
324
-
325
- **Essential Configuration:**
326
-
327
- ```javascript
328
- {
329
- // Core Settings
330
- "enabled": true,
331
- "createUserIfNotExists": true, // Auto-create users on first login
332
- "expire_period": 3600, // Token valid for 1 hour (3600s)
333
- "token_length": 20, // Token security level (20-40 chars)
334
-
335
- // Email Configuration
336
- "from_email": "noreply@yourdomain.com",
337
- "from_name": "Your App Name",
338
- "object": "Your Magic Link Login",
339
- "confirmationUrl": "https://yourdomain.com/auth/callback",
340
-
341
- // Rate Limiting
342
- "rate_limit_enabled": true,
343
- "rate_limit_max_attempts": 5, // 5 requests per window
344
- "rate_limit_window_minutes": 15 // 15 minute window
345
- }
155
+ ```json
156
+ { "email": "ada@example.com", "username": null, "sent": true, "delivery": "email" }
346
157
  ```
347
158
 
348
- **Choose Authentication Mode:**
349
- - Go to **Settings β†’ MFA & Login Modes**
350
- - Select your preferred mode (Magic Link Only is default and free)
351
- - Configure OTP/TOTP settings if using Premium/Advanced features
159
+ The answer is the same whether or not the account exists, so nobody can use it to find out who has an account. By default only existing users get a link; see [auto-registration](#auto-registration) to create accounts on first login.
352
160
 
353
- ### 3️⃣ Frontend Implementation
161
+ The public routes need no role configuration. They are open by design (`auth: false`) and protected by rate limits and IP bans.
354
162
 
355
- #### πŸ”— Basic Magic Link Flow
356
-
357
- **Step 1: Request a magic link**
358
- ```javascript
359
- const response = await fetch('/api/magic-link/send-link', {
360
- method: 'POST',
361
- headers: { 'Content-Type': 'application/json' },
362
- body: JSON.stringify({
363
- email: 'user@example.com',
364
- context: { redirectTo: '/dashboard' }
365
- })
366
- });
367
-
368
- const data = await response.json();
369
- console.log('Magic link sent!', data);
370
- ```
371
-
372
- **Step 2: Verify token on callback page**
373
- ```javascript
374
- const urlParams = new URLSearchParams(window.location.search);
375
- const loginToken = urlParams.get('loginToken');
376
-
377
- if (loginToken) {
378
- try {
379
- const response = await fetch(`/api/magic-link/login?loginToken=${loginToken}`);
380
- const { jwt, user } = await response.json();
381
-
382
- // Store JWT for authenticated requests
383
- localStorage.setItem('token', jwt);
384
- localStorage.setItem('user', JSON.stringify(user));
385
-
386
- // Redirect to dashboard
387
- window.location.href = '/dashboard';
388
- } catch (error) {
389
- console.error('Login failed:', error);
390
- alert('Invalid or expired magic link');
391
- }
392
- }
393
- ```
163
+ ## Frontend integration
394
164
 
395
- #### πŸ“§ Email OTP Flow (Premium)
165
+ A complete login has three parts: a form that requests the link, a landing page that exchanges the token, and a place to keep the JWT.
396
166
 
397
- **Step 1: Request magic link (same as above)**
167
+ ### 1. Request the link
398
168
 
399
- **Step 2: User clicks magic link β†’ redirected to OTP page**
169
+ ```js
170
+ // Login form, framework-agnostic
171
+ const API = 'https://api.example.com/api/magic-link';
400
172
 
401
- **Step 3: Verify OTP code**
402
- ```javascript
403
- async function verifyOTP(email, otpCode) {
404
- const response = await fetch('/api/magic-link/verify-otp', {
173
+ export async function requestMagicLink(email) {
174
+ const response = await fetch(`${API}/send-link`, {
405
175
  method: 'POST',
406
176
  headers: { 'Content-Type': 'application/json' },
407
- body: JSON.stringify({
408
- email: email,
409
- otp: otpCode
410
- })
177
+ // Optional context comes back in the login response (see below).
178
+ body: JSON.stringify({ email, context: { redirectTo: '/dashboard' } }),
411
179
  });
412
-
413
- if (response.ok) {
414
- const { jwt, user } = await response.json();
415
- localStorage.setItem('token', jwt);
416
- localStorage.setItem('user', JSON.stringify(user));
417
- window.location.href = '/dashboard';
418
- } else {
419
- alert('Invalid OTP code. Please try again.');
180
+ if (response.status === 429) {
181
+ throw new Error('Too many requests. Please wait a moment and try again.');
182
+ }
183
+ if (!response.ok) {
184
+ const { error } = await response.json();
185
+ throw new Error(error?.message || 'The link could not be requested.');
420
186
  }
187
+ // Always "sent": tell the user to check their inbox.
421
188
  }
422
-
423
- // Usage
424
- const otpInput = document.getElementById('otp-input');
425
- verifyOTP('user@example.com', otpInput.value);
426
189
  ```
427
190
 
428
- #### πŸ” TOTP Setup Flow (Advanced)
191
+ ### 2. Exchange the token on the landing page
192
+
193
+ The landing page receives `?loginToken=...`. Send the token to Strapi with **POST**, so it never shows up in access logs, caches or `Referer` headers, then react to the answer.
429
194
 
430
- **Step 1: Setup TOTP for user (requires authentication)**
431
- ```javascript
432
- async function setupTOTP() {
433
- const token = localStorage.getItem('token');
434
-
435
- const response = await fetch('/api/magic-link/totp/setup', {
195
+ ```js
196
+ // /auth/magic-link page
197
+ async function post(path, body) {
198
+ const response = await fetch(`${API}${path}`, {
436
199
  method: 'POST',
437
- headers: {
438
- 'Authorization': `Bearer ${token}`,
439
- 'Content-Type': 'application/json'
440
- }
200
+ headers: { 'Content-Type': 'application/json' },
201
+ credentials: 'include', // only needed for the httpOnly refresh cookie
202
+ body: JSON.stringify(body),
441
203
  });
442
-
443
- const { secret, qrCode, otpauthUrl } = await response.json();
444
-
445
- // Display QR code to user
446
- document.getElementById('qr-code').innerHTML = `<img src="${qrCode}" alt="Scan with authenticator app" />`;
447
-
448
- // Show secret for manual entry
449
- document.getElementById('secret').textContent = secret;
450
-
451
- return secret;
204
+ const data = await response.json();
205
+ if (!response.ok) throw Object.assign(new Error(data.error?.message), { data });
206
+ return data;
452
207
  }
453
- ```
454
208
 
455
- **Step 2: Verify TOTP code**
456
- ```javascript
457
- async function verifyTOTP(totpCode) {
458
- const token = localStorage.getItem('token');
459
-
460
- const response = await fetch('/api/magic-link/totp/verify', {
461
- method: 'POST',
462
- headers: {
463
- 'Authorization': `Bearer ${token}`,
464
- 'Content-Type': 'application/json'
465
- },
466
- body: JSON.stringify({
467
- token: totpCode
468
- })
469
- });
209
+ export async function completeMagicLink() {
210
+ const loginToken = new URLSearchParams(location.search).get('loginToken');
211
+ history.replaceState(null, '', location.pathname); // drop the token from the address bar
212
+
213
+ const result = await post('/login', { loginToken });
470
214
 
471
- if (response.ok) {
472
- alert('TOTP successfully enabled!');
473
- return true;
474
- } else {
475
- alert('Invalid code. Please try again.');
476
- return false;
215
+ if (result.requiresOTP) {
216
+ // An e-mailed code is required. Ask the user for it, then:
217
+ const code = await askUser('Enter the code from your e-mail');
218
+ return post('/otp/verify', { email: result.email, code, magicLinkToken: result.loginToken });
477
219
  }
478
- }
479
- ```
480
220
 
481
- **Step 3: Login with Email + TOTP (Alternative Login)**
482
- ```javascript
483
- async function loginWithTOTP(email, totpCode) {
484
- const response = await fetch('/api/magic-link/totp/login', {
485
- method: 'POST',
486
- headers: { 'Content-Type': 'application/json' },
487
- body: JSON.stringify({
488
- email: email,
489
- token: totpCode
490
- })
491
- });
221
+ if (result.requiresTOTP) {
222
+ // The user has an authenticator. A backup code works too: { loginToken, backupCode }
223
+ const totpCode = await askUser('Enter the code from your authenticator app');
224
+ return post('/verify-mfa-totp', { loginToken: result.loginToken, totpCode });
225
+ }
492
226
 
493
- if (response.ok) {
494
- const { jwt, user } = await response.json();
495
- localStorage.setItem('token', jwt);
496
- localStorage.setItem('user', JSON.stringify(user));
497
- window.location.href = '/dashboard';
498
- } else {
499
- alert('Invalid credentials');
227
+ if (result.requiresTOTPSetup) {
228
+ // MFA is required and the user has no authenticator yet: enrol now.
229
+ const { data } = await post('/totp/enroll', { loginToken: result.loginToken });
230
+ showQrCode(data.qrCode); // data URL; data.secret for manual entry
231
+ const totpCode = await askUser('Scan the QR code and enter the code');
232
+ const session = await post('/totp/enroll/confirm', { loginToken: result.loginToken, totpCode });
233
+ showBackupCodesOnce(session.backupCodes);
234
+ return session;
500
235
  }
236
+
237
+ return result; // { jwt, user, context, expires_at, refreshToken? }
501
238
  }
502
239
  ```
503
240
 
504
- #### πŸ” Check License Tier (Client-side)
241
+ `askUser`, `showQrCode` and `showBackupCodesOnce` stand for your own UI.
505
242
 
506
- ```javascript
507
- async function checkLicenseTier() {
508
- const adminToken = localStorage.getItem('adminToken');
509
-
510
- const response = await fetch('/magic-link/license/status', {
511
- headers: {
512
- 'Authorization': `Bearer ${adminToken}`
513
- }
514
- });
243
+ ### 3. Store the JWT and call your API
515
244
 
516
- const { data } = await response.json();
517
-
518
- // data.features.premium - Email OTP available
519
- // data.features.advanced - TOTP MFA available
520
- // data.features.enterprise - All features
521
-
522
- return {
523
- isPremium: data.features?.premium || false,
524
- isAdvanced: data.features?.advanced || false,
525
- isEnterprise: data.features?.enterprise || false
526
- };
527
- }
245
+ The `jwt` is a normal users-permissions JWT. Send it as `Authorization: Bearer <jwt>`.
528
246
 
529
- // Usage
530
- const license = await checkLicenseTier();
531
- if (license.isPremium) {
532
- console.log('Email OTP is available!');
533
- }
247
+ ```js
248
+ const session = await completeMagicLink();
249
+ sessionStorage.setItem('jwt', session.jwt); // or keep it in memory
250
+ location.assign(session.context?.redirectTo || '/');
251
+
252
+ // Later
253
+ const me = await fetch('https://api.example.com/api/users/me', {
254
+ headers: { Authorization: `Bearer ${sessionStorage.getItem('jwt')}` },
255
+ }).then((r) => r.json());
534
256
  ```
535
257
 
536
- ---
258
+ Anything in web storage can be read by a script injected into your page. If that matters for you, keep the JWT in memory and use [refresh mode](#refresh-mode-users-permissions-sessions) with the refresh token in an httpOnly cookie.
259
+
260
+ `GET /api/magic-link/login?loginToken=...` still works. You can point `confirmationUrl` straight at it, but the token then ends up in server logs, so prefer the POST exchange.
537
261
 
538
- ## πŸ“‘ API Endpoints
262
+ ### Where the link points
539
263
 
540
- ### 🌐 Public Endpoints (No Auth Required)
264
+ The link builder uses the first match:
541
265
 
542
- #### Authentication
543
- | Method | Endpoint | Description |
544
- |--------|----------|-------------|
545
- | `POST` | `/api/magic-link/send-link` | Generate and send magic link to email |
546
- | `GET` | `/api/magic-link/login?loginToken=xxx` | Authenticate user with token |
547
- | `POST` | `/api/magic-link/verify-otp` | Verify Email OTP code |
548
- | `POST` | `/api/magic-link/totp/login` | Login with Email + TOTP code |
266
+ 1. `frontend_url` + `frontend_login_path` (recommended)
267
+ 2. `confirmationUrl`
268
+ 3. `<server.url><api prefix>/magic-link/login` (the API route itself)
549
269
 
550
- #### TOTP Management (User Auth Required)
551
- | Method | Endpoint | Description |
552
- |--------|----------|-------------|
553
- | `POST` | `/api/magic-link/totp/setup` | Generate TOTP secret & QR code |
554
- | `POST` | `/api/magic-link/totp/verify` | Verify and enable TOTP |
555
- | `POST` | `/api/magic-link/totp/disable` | Disable TOTP for user |
556
- | `GET` | `/api/magic-link/totp/status` | Check if TOTP is configured |
270
+ Existing query strings are kept and `loginToken` is appended.
557
271
 
558
- ### πŸ” Admin Endpoints (Admin Auth Required)
272
+ ### Passing context through the link
559
273
 
560
- #### Token Management
561
- | Method | Endpoint | Description |
562
- |--------|----------|-------------|
563
- | `GET` | `/magic-link/tokens` | List all magic link tokens |
564
- | `POST` | `/magic-link/tokens` | Create a new token |
565
- | `DELETE` | `/magic-link/tokens/:id` | Permanently delete a token |
566
- | `POST` | `/magic-link/tokens/:id/block` | Block a token |
567
- | `POST` | `/magic-link/tokens/:id/activate` | Activate blocked token |
568
- | `POST` | `/magic-link/tokens/:id/extend` | Extend token validity |
274
+ `send-link` accepts a small `context` object, for example `{ "redirectTo": "/billing" }`. It comes back in the login response. Rules:
569
275
 
570
- #### Security & Monitoring
571
- | Method | Endpoint | Description |
572
- |--------|----------|-------------|
573
- | `GET` | `/magic-link/jwt-sessions` | List active JWT sessions |
574
- | `POST` | `/magic-link/revoke-jwt` | Revoke a JWT session |
575
- | `POST` | `/magic-link/ban-ip` | Ban an IP address |
576
- | `DELETE` | `/magic-link/ban-ip/:ip` | Unban an IP address |
577
- | `GET` | `/magic-link/banned-ips` | List all banned IPs |
578
- | `GET` | `/magic-link/rate-limit/stats` | Get rate limit statistics |
579
- | `POST` | `/magic-link/rate-limit/cleanup` | Cleanup expired entries |
276
+ - at most 20 keys;
277
+ - the `context_whitelist` / `context_blacklist` settings apply;
278
+ - reserved keys that carry authentication state (`otpVerified`, `mfaVerified`, `userId`, `exp`, ...) are rejected with 400.
580
279
 
581
- #### OTP Management
582
- | Method | Endpoint | Description |
583
- |--------|----------|-------------|
584
- | `GET` | `/magic-link/otp-codes` | List all OTP codes |
585
- | `POST` | `/magic-link/otp-codes/cleanup` | Cleanup expired OTP codes |
280
+ ## Second factors: e-mail OTP, TOTP and backup codes
586
281
 
587
- #### License Management
588
- | Method | Endpoint | Description |
589
- |--------|----------|-------------|
590
- | `GET` | `/magic-link/license/status` | Get current license status |
591
- | `POST` | `/magic-link/license/auto-create` | Create license with admin data |
592
- | `POST` | `/magic-link/license/activate` | Activate license with key |
282
+ Choose the mode in **Settings > Magic Link > Settings > Sign-in modes and MFA**.
593
283
 
594
- ---
284
+ | Mode | Setting | What the user does after clicking the link |
285
+ | --------------------- | ---------------------- | ----------------------------------------------------------- |
286
+ | Link only | defaults | Nothing, the link logs them in |
287
+ | Link + e-mail OTP | `otp_enabled: true` | Enters a 6-digit code sent in a second e-mail |
288
+ | Link + TOTP, optional | `mfa_mode: "optional"` | Users with an authenticator enter a code; others log in |
289
+ | Link + TOTP, required | `mfa_mode: "required"` | Everyone enters a code; users without one enrol on the spot |
595
290
 
596
- ## πŸ”„ Plugin Compatibility
291
+ **Switching to `required` while nobody is enrolled.** The settings API refuses `mfa_mode: "required"` (or the legacy `mfa_require_totp: true`) with `400` and `error.details.key: 'settings.mfa.noEnrolledUsers'` while no end user has an active authenticator, because a frontend without the enrolment step would lock everyone out. Start with `optional` so users can enrol, or, if your landing page handles `requiresTOTPSetup`, send `acknowledgeNoEnrolledUsers: true` with the update (`{ "settings": { "mfa_mode": "required" }, "acknowledgeNoEnrolledUsers": true }`). The flag is never stored. The admin panel blocks the option in the same situation.
292
+ | TOTP only | `totp_as_primary_auth: true` | Calls `POST /login-totp { email, totpCode }`, no e-mail |
597
293
 
598
- Magic Link provides **compatibility modes** for seamless migration from other authentication plugins. No frontend code changes required!
294
+ A user with an active authenticator always gets the TOTP step, even when e-mail OTP is also enabled.
599
295
 
600
- ### πŸ”— strapi-plugin-passwordless Migration
296
+ ### End-user TOTP enrolment
601
297
 
602
- If you're migrating from [strapi-plugin-passwordless](https://github.com/kucherenko/strapi-plugin-passwordless), enable the compatibility mode:
298
+ There are two ways for a user to add an authenticator.
603
299
 
604
- **Enable in Admin Panel:** Settings β†’ Magic Link β†’ Compatibility β†’ "Passwordless Plugin Compatibility"
300
+ **During sign-in (required MFA).** When `mfa_mode` is `required` and the user has no authenticator, `/login` answers `requiresTOTPSetup`. The landing page calls `POST /totp/enroll { loginToken }`, shows the QR code, and finishes with `POST /totp/enroll/confirm { loginToken, totpCode }`. That response is the session plus ten backup codes (see the [frontend example](#2-exchange-the-token-on-the-landing-page)). The pending setup is valid for 15 minutes. The link click already proved the mailbox, so no extra e-mail code is needed.
605
301
 
606
- When enabled, these additional routes become available:
302
+ **From the account (self-service).** These routes use the users-permissions JWT. Grant the **Magic-link > totp** actions (`status`, `setup`, `confirm`, `disable`, `backupCodes`) to the **Authenticated** role in _Settings > Users & Permissions > Roles_.
607
303
 
608
- | Original Route | Magic Link Handler | Description |
609
- |----------------|-------------------|-------------|
610
- | `POST /api/passwordless/send-link` | `auth.sendLink` | Send magic link email |
611
- | `GET /api/passwordless/login?loginToken=xxx` | `auth.login` | Authenticate with token |
304
+ ```js
305
+ const auth = { Authorization: `Bearer ${jwt}`, 'Content-Type': 'application/json' };
612
306
 
613
- **Request/Response Format:** Identical to the original plugin!
307
+ // 1. Start: returns the QR code and e-mails a confirmation code.
308
+ // With an active authenticator, also send { totpCode } from the current app.
309
+ const { data } = await fetch(`${API}/totp/setup`, {
310
+ method: 'POST',
311
+ headers: auth,
312
+ body: '{}',
313
+ }).then((r) => r.json());
314
+ // data: { secret, qrCode, otpauthUrl, algorithm, digits, period, expiresAt, emailCodeSent: true }
614
315
 
615
- ```javascript
616
- // Your existing code works without changes:
617
- const response = await fetch('/api/passwordless/send-link', {
316
+ // 2. Confirm with a code from the app AND the e-mailed code.
317
+ await fetch(`${API}/totp/confirm`, {
618
318
  method: 'POST',
619
- headers: { 'Content-Type': 'application/json' },
620
- body: JSON.stringify({
621
- email: 'user@example.com',
622
- username: 'John Doe', // Optional: username for new users
623
- context: { redirectTo: '/dashboard' }
624
- })
319
+ headers: auth,
320
+ body: JSON.stringify({ totpCode, emailCode }),
625
321
  });
322
+ // { data: { enabled: true, backupCodes: [10 codes, shown once] } }
626
323
 
627
- // Login response is identical:
628
- // { jwt: "...", user: {...}, context: {...} }
324
+ // Status, new backup codes (needs a current code), remove (TOTP or backup code)
325
+ await fetch(`${API}/totp/status`, { headers: auth });
326
+ // { data: { configured, enabled, pendingSetup, lastUsed, backupCodesRemaining,
327
+ // algorithm, digits, period, mfaMode } }
328
+ await fetch(`${API}/totp/backup-codes`, {
329
+ method: 'POST',
330
+ headers: auth,
331
+ body: JSON.stringify({ totpCode }),
332
+ });
333
+ await fetch(`${API}/totp/disable`, {
334
+ method: 'POST',
335
+ headers: auth,
336
+ body: JSON.stringify({ totpCode }),
337
+ });
629
338
  ```
630
339
 
631
- **Migration Steps:**
632
- 1. Install `strapi-plugin-magic-link-v5`
633
- 2. Remove `strapi-plugin-passwordless` from `package.json`
634
- 3. Enable **Passwordless Compatibility** in Settings
635
- 4. Restart Strapi - done! βœ…
636
-
637
- **Gradual Migration:** You can later switch your frontend to use `/api/magic-link/*` endpoints and disable compatibility mode.
340
+ A stolen JWT alone cannot add, replace or remove an authenticator:
638
341
 
639
- ### πŸ“§ Email Designer 5 Integration
342
+ - Setup needs the e-mailed code.
343
+ - Re-setup and new backup codes need a current TOTP code.
344
+ - Removal needs a TOTP code or a backup code.
640
345
 
641
- Magic Link is **fully compatible** with [strapi-plugin-email-designer-5](https://www.npmjs.com/package/strapi-plugin-email-designer-5)!
346
+ ### Backup codes
642
347
 
643
- **Enable in Admin Panel:** Settings β†’ Magic Link β†’ Compatibility β†’ "Email Designer 5 Integration"
348
+ - Ten 16-character codes (`XXXX-XXXX-XXXX-XXXX`), shown once.
349
+ - Stored as keyed hashes bound to their owner.
350
+ - Each code works once wherever a TOTP code is accepted after a magic link (`backupCode` instead of `totpCode`).
351
+ - The response reports `backupCodesRemaining`.
644
352
 
645
- ```bash
646
- # Install Email Designer 5
647
- npm install strapi-plugin-email-designer-5
648
- ```
353
+ Algorithm, digits and period are stored with each enrolment, so changing the TOTP settings only affects new enrolments. Each accepted time step is recorded, so a code cannot be replayed.
649
354
 
650
- **Template Variables:**
651
- | Variable | Description |
652
- |----------|-------------|
653
- | `{{magicLink}}` | Complete magic link URL with token |
654
- | `{{token}}` | Raw token value |
655
- | `{{user.email}}` | User's email address |
656
- | `{{user.username}}` | User's username |
657
- | `{{expiresAt}}` | Token expiration time |
355
+ ## Refresh mode (users-permissions sessions)
658
356
 
659
- **Configuration:**
660
- 1. Create a template in Email Designer
661
- 2. Go to Settings β†’ Magic Link β†’ Email Settings
662
- 3. Enable "Use Email Designer Template"
663
- 4. Select your template from the dropdown
357
+ With `jwtManagement: 'refresh'` in the users-permissions config, Magic Link logins behave like `/api/auth/local`: the plugin creates the session through the users-permissions session manager.
664
358
 
665
- ### πŸ›‘οΈ Security Note
359
+ **Versions.** The users-permissions session manager and `jwtManagement` exist since Strapi 5.24.0. Strapi's own `POST /api/auth/refresh` reads the refresh token from the httpOnly cookie only from 5.33.3 on (checked in the `@strapi/plugin-users-permissions` 5.33.2 and 5.33.3 sources); on 5.24 to 5.33.2 send the refresh token in the request body instead of using the cookie.
666
360
 
667
- **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.
361
+ ```js
362
+ // config/plugins.js
363
+ module.exports = {
364
+ 'users-permissions': {
365
+ config: {
366
+ jwtManagement: 'refresh',
367
+ sessions: { accessTokenLifespan: 600, maxRefreshTokenLifespan: 1209600, httpOnly: false },
368
+ },
369
+ },
370
+ };
371
+ ```
668
372
 
669
- ---
373
+ - The response contains a short-lived `jwt` and a `refreshToken`. With `sessions.httpOnly: true`, or the request header `x-strapi-refresh-cookie: httpOnly`, the refresh token is set as an httpOnly cookie (default name `strapi_up_refresh`) instead and left out of the body.
374
+ - Renew with the native `POST /api/auth/refresh`, sending `{ refreshToken }` or the cookie. Log out with `POST /api/auth/logout`.
375
+ - You may send `deviceId` with `/login`, `/verify-mfa-totp`, `/login-totp` or `/totp/enroll/confirm`, as with `/api/auth/local`. Without it, every Magic Link login gets its own device id.
376
+ - Revoking a session in **Magic Link > JWT Sessions** ends the whole users-permissions refresh family: the first refresh token and every token rotated from it. (`POST /api/auth/refresh` creates a new session id with the same device id; the plugin revokes by user and device id.) A client that reuses one `deviceId` for several logins gets all of them ended, like the users-permissions device logout.
377
+ - Access tokens minted from a revoked family are rejected as well. users-permissions itself accepts an access token until it expires. While a user has a revoked family (until the family's maximum lifetime plus one access-token lifespan), the plugin checks the users-permissions session of each of that user's access tokens (`isSessionActive`, one database lookup per request for that user only). It answers `401` when the session is gone and `503` when the state cannot be read.
378
+ - Session records written before the device id was stored can only revoke the first refresh token.
670
379
 
671
- ## βš™οΈ Configuration
380
+ In the default `legacy-support` mode the JWT lifetime comes from the plugin setting `jwt_token_expires_in` (for example `15m`, `12h`, `30d`).
672
381
 
673
- ### πŸ” Authentication Mode Settings
382
+ ## Public API
674
383
 
675
- **Configure in Admin Panel β†’ Settings β†’ MFA & Login Modes**
384
+ All routes are under `/api/magic-link` (or your `api.rest.prefix`). Every answer from these route handlers sends `Cache-Control: no-store` and `Referrer-Policy: no-referrer` (a request refused by the IP-ban check is answered before the handler runs). Unknown body fields are rejected with 400.
676
385
 
677
- ```javascript
678
- {
679
- // Authentication Modes (mutually exclusive)
680
- "otp_enabled": false, // Enable Email OTP (Premium)
681
- "mfa_require_totp": false, // Enable TOTP MFA (Advanced)
682
- "totp_as_primary_auth": false, // Enable TOTP-only login (Advanced)
683
-
684
- // Email OTP Configuration (Premium)
685
- "otp_type": "email", // "email" | "sms" | "totp"
686
- "otp_length": 6, // Code length (4-8 digits)
687
- "otp_expiry": 300, // Validity in seconds (default: 5 min)
688
- "otp_max_attempts": 3, // Max verification attempts
689
- "otp_resend_cooldown": 60, // Cooldown before resend (seconds)
690
-
691
- // TOTP Configuration (Advanced)
692
- "totp_issuer": "Magic Link", // Displayed in authenticator app
693
- "totp_digits": 6, // Code digits (6 or 8)
694
- "totp_period": 30, // Time period (seconds)
695
- "totp_algorithm": "sha1" // "sha1" | "sha256" | "sha512"
696
- }
697
- ```
386
+ | Method | Path | Auth | Body | Success response |
387
+ | ------ | ---------------------- | ----------------- | ---------------------------------------------------------- | -------------------------------------------------------------------------------- |
388
+ | `POST` | `/send-link` | none | `{ email, username?, context?, delivery? }` | `{ email, username, sent: true, delivery: "email" }` |
389
+ | `POST` | `/login` | none | `{ loginToken, deviceId? }` | session, or a challenge (`requiresOTP`, `requiresTOTP`, `requiresTOTPSetup`) |
390
+ | `GET` | `/login?loginToken=` | none | (query) | same as `POST /login` |
391
+ | `POST` | `/otp/send` | none | `{ email, magicLinkToken }` | `{ success, message, expiresIn, type: "email" }` |
392
+ | `POST` | `/otp/resend` | none | `{ email, magicLinkToken }` | `{ success, message, expiresIn }`, after `otp_resend_cooldown` |
393
+ | `POST` | `/otp/verify` | none | `{ email, code, magicLinkToken }` | session with `otpVerified: true` |
394
+ | `POST` | `/verify-mfa-totp` | none | `{ loginToken, totpCode }` or `{ loginToken, backupCode }` | session with `mfaVerified: true` |
395
+ | `POST` | `/login-totp` | none | `{ email, totpCode }` | session with `loginMethod: "totp"` (TOTP-only mode) |
396
+ | `POST` | `/totp/enroll` | none (loginToken) | `{ loginToken }` | `{ data: { secret, qrCode, otpauthUrl, algorithm, digits, period, expiresAt } }` |
397
+ | `POST` | `/totp/enroll/confirm` | none (loginToken) | `{ loginToken, totpCode }` | session plus `backupCodes` |
398
+ | `GET` | `/totp/status` | users-permissions | | `{ data: { configured, enabled, ..., mfaMode } }` |
399
+ | `POST` | `/totp/setup` | users-permissions | `{}` or `{ totpCode }` when already enrolled | `{ data: { secret, qrCode, ..., emailCodeSent: true } }` |
400
+ | `POST` | `/totp/confirm` | users-permissions | `{ totpCode, emailCode }` | `{ data: { enabled: true, backupCodes } }` |
401
+ | `POST` | `/totp/disable` | users-permissions | `{ totpCode }` or `{ backupCode }` | `{ data: { enabled: false } }` |
402
+ | `POST` | `/totp/backup-codes` | users-permissions | `{ totpCode }` | `{ data: { backupCodes } }` |
698
403
 
699
- ### βš™οΈ General Settings
404
+ `magicLinkToken` is the `loginToken` from the `/login` challenge. It is required while `otp_strict_binding` is on (the default).
700
405
 
701
- ```javascript
702
- {
703
- "enabled": true, // Enable/disable plugin
704
- "createUserIfNotExists": true, // Auto-create users on first login
705
- "expire_period": 3600, // Token expiration (seconds)
706
- "token_length": 20, // Token security level (20-40)
707
- "stays_valid": false, // Token reusable after first use
708
- "max_login_attempts": 5, // Limit failed login attempts
709
- "callback_url": "https://yourdomain.com/auth/callback"
710
- }
711
- ```
406
+ ### Session response
712
407
 
713
- ### πŸ“§ Email Settings
408
+ Every successful login answers like this:
714
409
 
715
- ```javascript
410
+ ```json
716
411
  {
717
- "from_name": "Your App",
718
- "from_email": "noreply@yourdomain.com",
719
- "response_email": "support@yourdomain.com",
720
- "object": "Your Magic Link Login",
721
- "message_html": "<h1>Welcome!</h1><p>Click to login: <a href='<%= URL %>?loginToken=<%= CODE %>'>Login</a></p>",
722
- "message_text": "Your login link: <%= URL %>?loginToken=<%= CODE %>"
412
+ "jwt": "eyJhbGciOi...",
413
+ "user": {
414
+ "id": 7,
415
+ "documentId": "abc123",
416
+ "username": "ada",
417
+ "email": "ada@example.com",
418
+ "confirmed": true,
419
+ "blocked": false
420
+ },
421
+ "context": { "redirectTo": "/billing" },
422
+ "expires_at": "2026-12-07T10:00:00.000Z",
423
+ "refreshToken": "only in refresh mode, unless the cookie is used"
723
424
  }
724
425
  ```
725
426
 
726
- **Template Variables:**
727
- - `<%= URL %>` - Your confirmation URL
728
- - `<%= CODE %>` - The generated token
729
- - `<%= USER %>` - User email/username
730
- - `<%= EXPIRES_AT %>` - Token expiration time
427
+ - The user object passes Strapi's content-API sanitizer, so private fields are never returned.
428
+ - Second-factor logins add `otpVerified: true`, `mfaVerified: true` (plus `backupCodesRemaining` after a backup code) or `loginMethod: "totp"`.
429
+ - With `use_jwt_token: false` no token is minted: the body has `user`, `context` and `jwtIssued: false`.
731
430
 
732
- ### πŸ”’ JWT Settings
431
+ ### Challenge responses from `/login`
733
432
 
734
- ```javascript
735
- {
736
- "use_jwt_token": true, // Use JWT for authentication
737
- "jwt_token_expires_in": "30d", // JWT validity (e.g., '7d', '30d', '365d')
738
- "store_login_info": true // Track IP and user agent
739
- }
433
+ ```json
434
+ { "requiresOTP": true, "message": "OTP verification required", "email": "ada@example.com", "loginToken": "...", "expiresIn": 300 }
435
+ { "requiresTOTP": true, "message": "TOTP verification required for MFA", "email": "ada@example.com", "loginToken": "..." }
436
+ { "requiresTOTPSetup": true, "message": "Set up an authenticator app to continue", "email": "ada@example.com", "loginToken": "...", "enrollEndpoint": "/api/magic-link/totp/enroll" }
740
437
  ```
741
438
 
742
- ### πŸ›‘οΈ Security & Rate Limiting
439
+ Opening the same link again while a challenge is pending returns the same challenge. It does not send a new e-mail while the code is still valid.
743
440
 
744
- ```javascript
441
+ ## Errors
442
+
443
+ Most errors use the Strapi error envelope with a stable `key`:
444
+
445
+ ```json
745
446
  {
746
- "rate_limit_enabled": true, // Enable rate limiting
747
- "rate_limit_max_attempts": 5, // Max requests per window
748
- "rate_limit_window_minutes": 15 // Time window in minutes
447
+ "data": null,
448
+ "error": {
449
+ "status": 400,
450
+ "name": "ValidationError",
451
+ "message": "Invalid or expired token",
452
+ "key": "token.invalid",
453
+ "details": { "key": "token.invalid" }
454
+ }
749
455
  }
750
456
  ```
751
457
 
752
- **How it works:**
753
- - Limits token requests per IP address
754
- - Limits token requests per email address
755
- - Returns `429 Too Many Requests` when exceeded
756
- - Automatic cleanup every 30 minutes
757
- - Protects against brute-force and spam attacks
758
-
759
- **Example:** With default settings (5 attempts per 15 minutes):
760
- - User can request max 5 magic links in 15 minutes
761
- - After 5 attempts, they must wait up to 15 minutes
762
- - View statistics in Settings β†’ Security & Rate Limiting
763
- - Manually cleanup expired entries or reset all limits
764
-
765
- ---
766
-
767
- ## πŸ”‘ Required Environment Variables
768
-
769
- The plugin stores secrets that must be protected across restarts and should
770
- not be rotated with the rest of your Strapi key material. Two env vars are
771
- **mandatory in production** (the plugin refuses to boot without them):
458
+ `message` is translated (`en`, `de`, `fr`, `es`, `pt`) from `?locale=` or `Accept-Language`. Build your UI on `key`, not on the text.
459
+
460
+ | Status | `key` | When |
461
+ | ------ | -------------------------- | -------------------------------------------------------------------------------- |
462
+ | 400 | `token.invalid` | The link is missing, expired, used, blocked, or its account no longer exists |
463
+ | 400 | `challenge.invalid` | The `loginToken` has no matching pending TOTP or enrolment step |
464
+ | 400 | `mfa.invalidCode` | Wrong TOTP or backup code |
465
+ | 400 | `credentials.invalid` | `/login-totp`: unknown account, no authenticator or wrong code (one answer) |
466
+ | 400 | `totp.noPendingSetup` | Confirm without a started setup, or the setup expired |
467
+ | 400 | `totp.invalidEmailCode` | `/totp/confirm`: the e-mailed code is wrong or expired |
468
+ | 400 | `totp.notEnrolled` | `/totp/disable` or `/totp/backup-codes` without an authenticator |
469
+ | 401 | `auth.required` | Self-service routes without a users-permissions JWT |
470
+ | 403 | `blocked.user` | The account is blocked |
471
+ | 403 | `totp.currentCodeRequired` | `/totp/setup` while an authenticator is active, without a current `totpCode` |
472
+ | 403 | `totp.loginDisabled` | `/login-totp` while `totp_as_primary_auth` is off |
473
+ | 409 | `totp.alreadyEnrolled` | `/totp/enroll` for a user who already has an authenticator |
474
+ | 429 | `rate.limit.exceeded` | Rate limit hit; `details.retryAfter` and the `Retry-After` header give seconds |
475
+ | 503 | `otp.deliveryFailed` | The OTP e-mail could not be sent (`details.resendEndpoint` points at the resend) |
476
+
477
+ Some answers do not carry a `key`. Handle them by status:
478
+
479
+ - **Body validation** (unknown field, bad e-mail, removed `phoneNumber`/`phone`/`via`/non-e-mail `delivery`): `400 ValidationError` with a message such as `Invalid request body at 'email': ...`.
480
+ - **IP bans**: `403 PolicyError` with `details.code: "IP_ADDRESS_BLOCKED"`, or `details.code: "IP_BAN_CHECK_UNAVAILABLE"` when the ban list cannot be read.
481
+ - **`/otp/send`, `/otp/resend`, `/otp/verify`**: plain Strapi errors.
482
+ - A wrong or expired code answers `400` "Invalid or expired verification code".
483
+ - Limits answer `429` with a "try again in N seconds" message and a `Retry-After` header.
484
+ - A failed OTP e-mail answers `503 { "success": false, "error": { "code": "OTP_EMAIL_FAILED", ... } }`.
485
+ - A `magicLinkToken` that does not match gets the same answer as a valid one (`{ "success": true, "message": "If the request is valid, a verification code will be sent." }`), so the route cannot be used to test tokens.
486
+ - **Revoked JWTs** on any Content API route: `401 UnauthorizedError` "This token has been revoked"; `503` when the revocation state cannot be read.
487
+
488
+ ## Admin API
489
+
490
+ Admin routes live under `/magic-link` on the Strapi server (not under `/api`). They need an admin JWT (`Authorization: Bearer <admin token>`) and the listed permission. The admin pages use them; you rarely need them directly.
491
+
492
+ | Method | Path | Permission | Purpose |
493
+ | -------- | ------------------------------------- | ----------------- | ------------------------------------------------------------------------------------------- |
494
+ | `GET` | `/magic-link/tokens` | `read` | Newest 1000 tokens, or `?page=&pageSize=` (max 1000) |
495
+ | `POST` | `/magic-link/tokens` | `manage` | Create a link: `{ email, ttl?, send_email?, context? }` (ttl in hours, 1-720) |
496
+ | `POST` | `/magic-link/tokens/:id/block` | `manage` | Deactivate a link |
497
+ | `POST` | `/magic-link/tokens/:id/activate` | `manage` | Re-activate (refused for used single-use or expired links) |
498
+ | `POST` | `/magic-link/tokens/:id/extend` | `manage` | `{ days }`, 1-365 (default 7) |
499
+ | `DELETE` | `/magic-link/tokens/:id` | `manage` | Delete a link |
500
+ | `GET` | `/magic-link/jwt-sessions` | `read` | Sessions issued by Magic Link |
501
+ | `POST` | `/magic-link/revoke-jwt` | `manage` | `{ sessionId }` or `{ token }` |
502
+ | `POST` | `/magic-link/unrevoke-jwt` | `manage` | `{ sessionId }` (not for refresh-mode sessions) |
503
+ | `POST` | `/magic-link/cleanup-sessions` | `manage` | Remove expired session records |
504
+ | `GET` | `/magic-link/banned-ips` | `read` | `{ ips: [...] }` |
505
+ | `POST` | `/magic-link/ban-ip` | `manage` | `{ data: { ip } }` |
506
+ | `POST` | `/magic-link/unban-ip` | `manage` | `{ data: { ip } }` |
507
+ | `GET` | `/magic-link/otp/codes` | `read` | `?page=&pageSize=&email=&type=&status=`; codes, stats and pagination (no code hashes) |
508
+ | `DELETE` | `/magic-link/otp/codes/:id` | `manage` | Delete one code |
509
+ | `POST` | `/magic-link/otp/cleanup` | `manage` | Remove expired codes |
510
+ | `GET` | `/magic-link/settings` | `settings.read` | Read settings |
511
+ | `PUT` | `/magic-link/settings` | `settings.update` | Save settings (only known keys, validated); security settings also need `settings.security` |
512
+ | `POST` | `/magic-link/reset-data` | `settings.update` | Restore defaults: `{ confirm: 'RESET', resetSecurity? }`; also needs `settings.security` |
513
+ | `GET` | `/magic-link/rate-limit/stats` | `settings.read` | Limiter statistics |
514
+ | `POST` | `/magic-link/rate-limit/cleanup` | `settings.update` | Drop expired limiter entries |
515
+ | `POST` | `/magic-link/rate-limit/reset` | `settings.update` | Reset all limits |
516
+ | `GET` | `/magic-link/totp/enrollment-summary` | `settings.read` | `{ data: { enrolled, enrollmentDuringLogin } }` |
517
+ | `GET` | `/magic-link/license/status` | `settings.read` | License state; full key only with `settings.update` |
518
+ | `POST` | `/magic-link/license/store-key` | `settings.update` | Save a license key |
519
+ | `GET` | `/magic-link/otp/totp/status` | `read` | The calling admin's own authenticator |
520
+ | `POST` | `/magic-link/otp/totp/setup` | `read` | Start setup (`{ token }` when one is active) |
521
+ | `POST` | `/magic-link/otp/totp/verify` | `read` | Confirm setup, returns backup codes |
522
+ | `POST` | `/magic-link/otp/totp/disable` | `read` | `{ token }` |
523
+ | `POST` | `/magic-link/otp/totp/backup-codes` | `read` | `{ token }` |
524
+
525
+ A missing permission answers `403`; a missing or expired admin token answers `401`.
526
+
527
+ `reset-data` needs `settings.security` as well as `settings.update`, because it also deletes every login token, the JWT session list and all IP bans and resets link targets, templates, sender and code rules. The MFA settings are kept unless `resetSecurity: true` is sent. Active JWT revocations are kept. Without the session list, refresh-mode sessions can no longer be revoked from the admin panel.
528
+
529
+ #### Example: create and e-mail a link
772
530
 
773
531
  ```bash
774
- # 32+ random bytes β€” used as AES-256 key for TOTP secrets.
775
- # DO NOT ROTATE: existing TOTP secrets would become undecryptable.
776
- MAGIC_LINK_ENCRYPTION_KEY=<hex from: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))">
777
-
778
- # 16+ random bytes β€” pepper mixed into OTP code hashes to defeat
779
- # rainbow-table attacks on stored 6-digit codes if the DB is ever leaked.
780
- MAGIC_LINK_OTP_PEPPER=<hex from: node -e "console.log(require('crypto').randomBytes(24).toString('hex'))">
532
+ curl -X POST http://localhost:1337/magic-link/tokens \
533
+ -H "Authorization: Bearer $ADMIN_JWT" -H 'Content-Type: application/json' \
534
+ -d '{"email":"ada@example.com","ttl":24,"context":{"redirectTo":"/onboarding"}}'
781
535
  ```
782
536
 
783
- In non-production environments (`NODE_ENV !== 'production'`) the plugin
784
- falls back to derived values so local dev setups keep working β€” but a WARN
785
- is logged once per process.
786
-
787
- ---
788
-
789
- ## 🌐 Reverse Proxy / Rate-Limit Trust
790
-
791
- Rate limits and all audit logs identify the caller by `ctx.request.ip`,
792
- which Koa derives from the TCP connection by default. If your Strapi is
793
- behind a reverse proxy (nginx, Caddy, Cloudflare, ALB, …) you MUST:
794
-
795
- 1. Enable proxy trust in `config/server.{js,ts}`:
796
-
797
- ```js
798
- module.exports = ({ env }) => ({
799
- proxy: true, // Trust X-Forwarded-For from the proxy
800
- // ... rest of config
801
- });
802
- ```
803
-
804
- 2. Configure the proxy to **strip any incoming `X-Forwarded-For`** and
805
- emit only its own value. Without this, an attacker can spoof the
806
- header, defeating per-IP rate limits.
807
-
808
- 3. If you have multiple proxy hops, pin trust to the outermost ones via
809
- Koa's `app.proxyIpHeader` / `app.maxIpsCount` (see Koa docs).
810
-
811
- A misconfigured deployment will either:
812
- - **Count all requests as the proxy's single IP** (everyone rate-limited together), or
813
- - **Trust spoofed client IPs** (per-IP rate limit effectively disabled).
814
-
815
- ---
816
-
817
- ## πŸ” OTP Strict Binding
818
-
819
- Starting with this release, `/otp/send`, `/otp/verify` and `/otp/resend`
820
- require the client to present the `magicLinkToken` they received from
821
- `/magic-link/login` when it returned `{ requiresOTP: true }`. This prevents
822
- `/otp/send` from being abused as a standalone email-bombing vector against
823
- arbitrary addresses and prevents `/otp/verify` from being a first-factor
824
- login on its own.
825
-
826
- If you have legacy integrations that must keep the old behaviour, disable
827
- it via settings: `otp_strict_binding: false`. Doing so emits a WARN on
828
- every request and is **not recommended**.
829
-
830
- ---
831
-
832
- ## 🎨 Email Templates
833
-
834
- Customize your magic link emails using template variables:
835
-
836
- ```html
837
- <!-- HTML Template -->
838
- <h1>Welcome!</h1>
839
- <p>Click to login:</p>
840
- <a href="<%= URL %>?loginToken=<%= CODE %>">
841
- Login to Your Account
842
- </a>
537
+ ```json
538
+ {
539
+ "id": 42,
540
+ "documentId": "k2m9x0...",
541
+ "email": "ada@example.com",
542
+ "expires_at": "2026-11-08T10:00:00.000Z",
543
+ "is_active": true,
544
+ "is_used": false,
545
+ "context": { "redirectTo": "/onboarding" },
546
+ "sent": true
547
+ }
843
548
  ```
844
549
 
845
- **Available Variables:**
846
- - `<%= URL %>` - Your confirmation URL
847
- - `<%= CODE %>` - The generated token
848
-
849
- ---
850
-
851
- ## πŸ”’ Security Features
852
-
853
- - **Token Expiration** - Configurable expiration periods
854
- - **One-time Tokens** - Optional single-use tokens
855
- - **IP Tracking** - Monitor login locations
856
- - **IP Banning** - Block suspicious addresses
857
- - **JWT Blacklist** - Revoke compromised sessions
858
- - **Login Attempt Limiting** - Prevent brute force
859
- - **User Agent Tracking** - Device fingerprinting
860
-
861
- ---
862
-
863
- ## 🎯 Use Cases
864
-
865
- - **SaaS Applications** - Simplify user onboarding
866
- - **Customer Portals** - Secure, password-free access
867
- - **Multi-tenant Systems** - Easy user management
868
- - **Mobile Apps** - Seamless authentication flow
869
- - **Content Platforms** - Reduce password fatigue
870
-
871
- ---
872
-
873
- ## πŸ› Troubleshooting
874
-
875
- ### Common Issues
876
-
877
- | Issue | Solution |
878
- |-------|----------|
879
- | **Emails not sending** | Check Strapi email provider configuration in `config/plugins.js` |
880
- | **Token invalid errors** | Verify token hasn't expired. Check `expire_period` setting |
881
- | **User not found** | Enable `createUserIfNotExists` setting in admin panel |
882
- | **License activation fails** | Check network connectivity. Try manual activation with license key |
883
- | **npm install fails** | Use `npm install --legacy-peer-deps` or update to latest npm |
884
- | **OTP settings not visible** | Check license tier. Email OTP requires Premium license |
885
- | **TOTP not working** | Verify Advanced license is active. Check authenticator app time sync |
886
- | **"Current License: Free" but Premium activated** | Refresh browser cache. License info loads from `/magic-link/license/status` |
887
- | **Lock icons not centered** | Update to v4.16.1+. Mobile UI fixes are included |
888
- | **Email OTP shown without license** | Update to v4.16.3+. License validation is fixed |
550
+ - With `send_email: false` the link is not mailed. The response then also carries `token` and `loginUrl`, once, which lets the admin log in as that user. That needs the separate `tokens.issue` permission (otherwise `403 PolicyError` with `details.key: "tokens.issue.forbidden"`).
551
+ - An unknown address gets an account only when `createUserIfNotExists` is on; otherwise `400`. A blocked user answers `400`.
552
+ - Creation is limited to 60 per admin per 15 minutes (`429` with `details.retryAfter`).
553
+ - If the e-mail cannot be sent, the token is deactivated and the answer is `503`.
889
554
 
890
- ### Debug Mode
891
-
892
- Enable debug logging in your Strapi instance:
555
+ #### Example: revoke a session and ban an address
893
556
 
894
557
  ```bash
895
- # In development
896
- NODE_ENV=development npm run develop
897
-
898
- # Check logs in terminal for Magic Link debug output
558
+ curl -X POST http://localhost:1337/magic-link/revoke-jwt \
559
+ -H "Authorization: Bearer $ADMIN_JWT" -H 'Content-Type: application/json' \
560
+ -d '{"sessionId":"session_1762500000000_a1b2c3d4e5f6"}'
561
+ # { "success": true, "message": "JWT session revoked successfully" } (404 when the id is unknown)
562
+
563
+ curl -X POST http://localhost:1337/magic-link/ban-ip \
564
+ -H "Authorization: Bearer $ADMIN_JWT" -H 'Content-Type: application/json' \
565
+ -d '{"data":{"ip":"203.0.113.50"}}'
566
+ # { "success": true, "message": "IP 203.0.113.50 has been banned" } (400 for an invalid address)
899
567
  ```
900
568
 
901
- ### Reset Plugin
902
-
903
- If you need to reset the plugin:
904
-
905
- ```bash
906
- # 1. Stop Strapi
907
- # 2. Clear plugin database entries
908
- # 3. Restart Strapi
909
- npm run develop
569
+ ## Permissions
570
+
571
+ ### Admin roles (Settings > Administration Panel > Roles > Plugins > Magic Link)
572
+
573
+ | Action | Grants |
574
+ | -------------------------------------- | ------------------------------------------------------------------------ |
575
+ | `plugin::magic-link.read` | Menu entry and Tokens page: tokens, OTP codes, JWT sessions, IP bans |
576
+ | `plugin::magic-link.manage` | Create, block, extend, delete tokens; revoke sessions; ban IPs; cleanups |
577
+ | `plugin::magic-link.tokens.issue` | Create links without e-mail (impersonation). Grant sparingly. |
578
+ | `plugin::magic-link.settings.read` | Settings and License pages |
579
+ | `plugin::magic-link.settings.update` | Save settings, rate-limit actions, license changes |
580
+ | `plugin::magic-link.settings.security` | Change security settings (see below), reset MFA settings |
581
+
582
+ The server enforces these on every route; the admin pages hide what a role cannot do. Super Admin has all of them.
583
+
584
+ **Role migration from 5.x.** 5.x had a single `plugin::magic-link.access` action. On the first start of 6.0.0, every admin role (and admin API token) that still holds it gets **Read** and **Manage**, and the old permission is removed. `tokens.issue`, `settings.read`, `settings.update` and `settings.security` are never granted automatically. After the upgrade, open each role and grant **Settings > Read** to roles that used the License page. If Strapi already dropped the unknown `access` action before the migration ran, there is nothing to migrate: grant the new actions by hand.
585
+
586
+ **Security settings** need `settings.security` in addition to `settings.update`. They can redirect login links (and the login token in them) to another site, put the raw token into a template, or change who may log in and with which factors: `confirmationUrl`, `callback_url`, `frontend_url`, `frontend_login_path`, `login_path`, `passwordlessCompatibility`, `message_html`, `message_text`, the Email Designer and Magic Mail template fields, `enabled`, `createUserIfNotExists`, `user_creation_strategy`, `allow_magic_links_on_public_registration`, `verify_email`, `stays_valid`, `expire_period`, `token_length`, `use_jwt_token`, `jwt_token_expires_in`, `context_whitelist`, `context_blacklist`, `mfa_mode`, `mfa_require_totp`, `totp_as_primary_auth`, `otp_enabled`, `otp_strict_binding`, and the code and brute-force rules `otp_length`, `otp_expiry`, `otp_max_attempts`, `otp_resend_cooldown`, `totp_algorithm`, `totp_digits`, `totp_period`, `rate_limit_enabled`, `rate_limit_max_attempts`, `rate_limit_window_minutes`, `rate_limit_login_*`, and the sender identity `from_name`, `from_email` and `response_email`.
587
+
588
+ - Sending them unchanged is allowed, so a role with `settings.update` only can still save the subject, welcome mail, login-info and TOTP issuer.
589
+ - `reset-data` needs `settings.security`.
590
+ - A refused change answers `403` with `error.details.key: 'settings.security.forbidden'` and the field names; nothing is written.
591
+ - `settings.security` is never granted automatically. Grant it only to roles that may decide where login links point (Super Admin has it).
592
+
593
+ For a hard limit that no admin can change, list the allowed link origins in the server config (`allowedLinkOrigins`, see [Plugin config](#plugin-config-configpluginsjs)).
594
+
595
+ ### Users & Permissions roles
596
+
597
+ - **Public:** nothing to grant. The login routes are public (`auth: false`) and protected by rate limits and IP bans.
598
+ - **Authenticated:** grant the **Magic-link > totp** actions if users should manage their authenticator themselves.
599
+
600
+ ## Configuration reference
601
+
602
+ ### Settings (admin panel)
603
+
604
+ Stored in the plugin store and edited in **Settings > Magic Link > Settings**. Unknown keys are ignored.
605
+
606
+ | Key | Default | Meaning |
607
+ | ---------------------------------------------------------------------------------------------------- | ------------------------------- | --------------------------------------------------------------------- |
608
+ | `enabled` | `true` | Master switch |
609
+ | `expire_period` | `3600` | Link lifetime in seconds (10 to 31536000) |
610
+ | `token_length` | `32` | Link token length, 22 to 128 characters |
611
+ | `stays_valid` | `false` | Allow a link to be used more than once until it expires |
612
+ | `frontend_url`, `frontend_login_path` | `''`, `/auth/magic-link` | Landing page for links (https; http only for localhost) |
613
+ | `confirmationUrl` | `''` | Alternative link target when no frontend URL is set |
614
+ | `createUserIfNotExists` | `true` | Allow auto-registration (needs the next one and sign-ups as well) |
615
+ | `allow_magic_links_on_public_registration` | `false` | Send links to unknown addresses and create the account on first login |
616
+ | `user_creation_strategy` | `email` | `email`, `emailUsername` or `manual` username for new accounts |
617
+ | `use_jwt_token`, `jwt_token_expires_in` | `true`, `30d` | Issue a JWT; lifetime in legacy mode (`15m`, `12h`, `30d`) |
618
+ | `store_login_info` | `true` | Record IP address and user agent on the token |
619
+ | `context_whitelist`, `context_blacklist` | `[]`, `[password, ...]` | Context keys allowed or always removed |
620
+ | `from_name`, `from_email`, `response_email` | see admin | Sender; an empty `from_email` uses the e-mail plugin `defaultFrom` |
621
+ | `object`, `message_html`, `message_text` | built-in template | Subject and bodies, see [E-mail templates](#e-mail-templates) |
622
+ | `use_magic_mail`, `magic_mail_template_id` | `false` | Send through Magic Mail |
623
+ | `use_email_designer`, `email_designer_template_id` | `false` | Send through Email Designer 5 |
624
+ | `otp_enabled` | `false` | E-mail OTP after the link |
625
+ | `otp_length`, `otp_expiry` | `6`, `300` | Code length (4 to 16) and lifetime in seconds |
626
+ | `otp_max_attempts`, `otp_resend_cooldown` | `3`, `60` | Attempts per code; seconds before a resend |
627
+ | `otp_strict_binding` | `true` | OTP routes need the `magicLinkToken` from `/login`. Keep it on. |
628
+ | `mfa_mode` | `disabled` | `disabled`, `optional` or `required` TOTP after the link |
629
+ | `totp_as_primary_auth` | `false` | Enable `POST /login-totp` |
630
+ | `totp_issuer`, `totp_algorithm`, `totp_digits`, `totp_period` | `Magic Link`, `SHA1`, `6`, `30` | Defaults for new enrolments |
631
+ | `rate_limit_enabled` | `true` | Rate limiting on the public routes |
632
+ | `rate_limit_max_attempts`, `rate_limit_window_minutes` | `5`, `15` | Requests per IP and per address in the window |
633
+ | `rate_limit_login_max_attempts`, `rate_limit_login_window_minutes`, `rate_limit_login_block_minutes` | `10`, `15`, `30` | Link exchanges and TOTP logins per IP, and the block after the limit |
634
+ | `passwordlessCompatibility` | `false` | Serve `/api/passwordless/*` |
635
+
636
+ ### Plugin config (`config/plugins.js`)
637
+
638
+ ```js
639
+ module.exports = ({ env }) => ({
640
+ 'magic-link': {
641
+ config: {
642
+ // Shared rate limits for several Strapi instances (rate-limiter-flexible Redis store).
643
+ rateLimit: { redis: { storeClient: redisClient, useRedisPackage: true } },
644
+ // Disable all calls to the license server.
645
+ license: { remoteLookup: false },
646
+ // Optional: login links may only point at these origins. A changed
647
+ // confirmationUrl, frontend_url or callback_url outside the list is
648
+ // rejected with 400 (key settings.linkOrigin.notAllowed).
649
+ allowedLinkOrigins: ['https://app.example.com'],
650
+ },
651
+ },
652
+ });
910
653
  ```
911
654
 
912
- ### Get Support
913
-
914
- - πŸ› [GitHub Issues](https://github.com/Schero94/strapi-plugin-magic-link-v5/issues)
915
- - πŸ“– Check the README for updated examples
916
-
917
- ---
918
-
919
- ## 🀝 Contributing
655
+ `allowedLinkOrigins` entries must be bare origins such as `https://app.example.com`; anything else stops Strapi at startup with a clear error.
920
656
 
921
- Contributions are welcome! Please:
657
+ ### Environment variables
922
658
 
923
- 1. Fork the repository
924
- 2. Create a feature branch (`git checkout -b feature/amazing-feature`)
925
- 3. Commit your changes (`git commit -m 'feat: add amazing feature'`)
926
- 4. Push to the branch (`git push origin feature/amazing-feature`)
927
- 5. Open a Pull Request
659
+ | Variable | Required | Purpose |
660
+ | ------------------------------------ | -------- | ------------------------------------------------------------- |
661
+ | `MAGIC_LINK_ENCRYPTION_KEY` | yes | AES-256-GCM key for TOTP secrets, 32+ characters |
662
+ | `MAGIC_LINK_OTP_PEPPER` | yes | Key for OTP code hashes and backup-code HMACs, 32+ characters |
663
+ | `MAGIC_LINK_ENCRYPTION_KEY_PREVIOUS` | no | Old key during rotation; secrets are re-encrypted at startup |
664
+ | `MAGIC_LINK_ALLOW_DEV_SECRETS` | no | `true` allows derived development secrets outside production |
665
+ | `MAGIC_LINK_LICENSE_LOOKUP` | no | `false` disables license-server calls |
928
666
 
929
- **Commit Convention:** Follow [Conventional Commits](https://www.conventionalcommits.org/)
930
- - `feat:` - New features
931
- - `fix:` - Bug fixes
932
- - `docs:` - Documentation changes
933
- - `chore:` - Maintenance tasks
667
+ **Rotating the encryption key:**
934
668
 
935
- ---
669
+ 1. Put the new key in `MAGIC_LINK_ENCRYPTION_KEY` and the old one in `MAGIC_LINK_ENCRYPTION_KEY_PREVIOUS`.
670
+ 2. Restart Strapi. TOTP secrets are re-encrypted at startup.
671
+ 3. Remove `MAGIC_LINK_ENCRYPTION_KEY_PREVIOUS`.
936
672
 
937
- ## Changelog
673
+ Changing the pepper only invalidates pending e-mail codes and backup codes.
938
674
 
939
- ### v5.2.7 (2025-12-16) - Latest
675
+ ### Auto-registration
940
676
 
941
- **WhatsApp Integration**
942
- - **WhatsApp Magic Links** - Send authentication links via WhatsApp messenger (FREE!)
943
- - **Customizable Templates** - Configure WhatsApp message text with variables
944
- - **Frontend URL Setting** - Set custom frontend login URLs for magic links
945
- - **Magic-Mail Integration** - Automatic delegation when both plugins installed
946
- - **QR Code Setup** - Easy WhatsApp connection via QR code scan
947
- - **Debug Mode** - Optional verbose logging for WhatsApp connections
677
+ An unknown address gets an account only when all three are on:
948
678
 
949
- **New Settings:**
950
- - `frontend_login_url` - Custom frontend URL for magic links
951
- - `whatsapp_message_text` - WhatsApp message template
952
- - `whatsapp_app_name` - App name displayed in WhatsApp messages
953
- - `whatsapp_debug` - Enable/disable verbose WhatsApp logging
679
+ - `createUserIfNotExists`
680
+ - `allow_magic_links_on_public_registration`
681
+ - users-permissions **Enable sign-ups** (`allow_register`)
954
682
 
955
- ### v5.1.10 (2025-12-15)
683
+ New accounts start unconfirmed and are confirmed by their first magic-link login. If a username is already taken, a random suffix is added.
956
684
 
957
- **Plugin Compatibility Mode**
958
- - **Passwordless Plugin Compatibility** - Migration support for `strapi-plugin-passwordless`
959
- - Added `/api/passwordless/send-link` and `/api/passwordless/login` routes
960
- - Identical request/response format - no frontend changes needed!
961
- - Disabled by default for security (returns 404 when off)
962
- - **Email Designer 5 Integration** - Enhanced template support
963
- - New "Compatibility" settings section in admin UI
964
- - Full i18n support for compatibility settings (DE, EN, ES, FR, PT)
965
- - `compatibility-check` policy for secure route access
685
+ ## E-mail templates
966
686
 
967
- ### v4.16.3 (2025-11-22)
687
+ Subject, HTML and text bodies use `{{PLACEHOLDER}}` syntax. Values are HTML-escaped in the HTML body, and no template code is ever executed. The admin preview and real e-mails use the same renderer.
968
688
 
969
- **License Validation Fix**
970
- - βœ… Fixed license tier detection from backend features
971
- - βœ… Correctly extracts `premium`, `advanced`, `enterprise` from license response
972
- - βœ… Enriched license info with `tier` field for UI compatibility
973
- - πŸ› Fixed "Current License: Free" display bug when Premium is active
689
+ | Placeholder | Value |
690
+ | ----------------- | ------------------------------------------- |
691
+ | `{{LOGIN_URL}}` | Full login link including the token |
692
+ | `{{URL}}` | Link target without the token |
693
+ | `{{CODE}}` | The login token on its own |
694
+ | `{{EXPIRY_TEXT}}` | Human-readable lifetime, such as "1 hour" |
695
+ | `{{EXPIRES_AT}}` | Expiry as an ISO date |
696
+ | `{{EMAIL}}` | Recipient address |
697
+ | `{{USER}}` | Username, or the address when there is none |
974
698
 
975
- ### v4.16.2 (2025-11-22)
699
+ Templates from 5.x that use `<%= URL %>?loginToken=<%= CODE %>` are converted to `{{LOGIN_URL}}` at startup. Any other `<% ... %>` block is printed as plain text.
976
700
 
977
- **Active State & Auto-Disable Fix**
978
- - βœ… "Magic Link Only" now always shows ACTIVE with FREE license
979
- - βœ… Email OTP settings only visible with Premium+ license
980
- - βœ… Auto-disable premium features when license doesn't support them
981
- - βœ… Added useEffect hook for automatic license enforcement
701
+ **Magic Mail and Email Designer 5** receive these variables: `magicLink`, `loginUrl`, `url`, `code`, `token`, `email`, `expiryText`, `expiresAt`, `user.email`, `user.username`. Choose the template in **Settings > E-mail**. Magic Mail sends login mails as `transactional` without open or link tracking. If the provider fails, the plugin falls back to the Strapi e-mail plugin.
982
702
 
983
- ### v4.16.1 (2025-11-22)
703
+ ## Security model
984
704
 
985
- **Lock Icons & License Checks**
986
- - βœ… Perfectly centered lock/lightning icons in locked mode cards
987
- - βœ… Fixed license validation in active state detection
988
- - βœ… Locked features now show proper disabled state
989
- - 🎨 Improved locked overlay with flexbox centering
705
+ What is protected, and how:
990
706
 
991
- ### v4.16.0 (2025-11-22)
707
+ - **Login links**
708
+ - Tokens have 132 or more bits of entropy and are stored as salted hashes. Only an 8-character lookup prefix is kept in clear.
709
+ - A single-use link is consumed with a conditional update, so parallel clicks yield exactly one session.
710
+ - Links expire after `expire_period`.
711
+ - **No account discovery.** `send-link` answers before any lookup or delivery: identical body, identical timing. `/login-totp` gives one answer for unknown accounts, missing authenticators and wrong codes.
712
+ - **Server-side challenge state.**
713
+ - Second-factor progress lives in a private token field, never in client-supplied context.
714
+ - Reserved context keys are rejected.
715
+ - Public and admin context pass the same allow/deny policy.
716
+ - **Second factors**
717
+ - OTP attempts are counted atomically before the comparison.
718
+ - Code checks (TOTP, backup and e-mail codes) are limited to 10 per account and 15 minutes, also when rate limiting is turned off. Neither the admin rate-limit reset nor a change of the public limits clears this counter.
719
+ - Every accepted TOTP time step is recorded, so a code cannot be replayed.
720
+ - TOTP secrets are encrypted with AES-256-GCM.
721
+ - Backup codes are owner-bound HMACs.
722
+ - Admin and end-user authenticators live in separate namespaces (`admin:<id>`, `up:<id>`).
723
+ - **Sessions**
724
+ - The JWT lifetime is honoured, and a revoked token stays blocked until it would have expired. A legacy JWT without `exp` (users-permissions configured without `expiresIn`) is blocked permanently, so its revocation never lapses; remove such an entry only with "restore".
725
+ - Every legacy JWT carries a random `jti`, so revoking one session never ends another session of the same user.
726
+ - In refresh mode, revocation ends the whole refresh family (rotated tokens included) and rejects access tokens minted from it (see [Refresh mode](#refresh-mode-users-permissions-sessions)).
727
+ - The `Authorization` header is parsed like users-permissions does it. The revocation check runs on Content API requests (REST and GraphQL) only and fails closed (503) there; admin routes are never affected. Paths are matched case-insensitively, like the Strapi routers (`/API/users/me` is checked too).
728
+ - **Pre-account hijacking.** With e-mail confirmation on, someone can register through `/api/auth/local/register` with another person's address and a password of their choice. When the mailbox owner then confirms that unconfirmed account through a magic link, an e-mail OTP or the TOTP enrolment during login, the plugin first ends every existing session of the account (plugin JWTs and all users-permissions refresh families), then confirms it with the password, reset token and confirmation token cleared. The owner sets a password through "forgot password" if they want password login. Already confirmed accounts are not touched.
729
+ - **MFA policy changes.** A challenge that was started before the MFA settings (or the user's authenticator) changed is re-checked when it completes. An e-mail OTP no longer completes a login once MFA is required or the user enrolled an authenticator; opening the link again starts the required challenge.
730
+ - **Scope of MFA.** The MFA settings apply to the Magic Link login paths. Other ways to get a users-permissions session, such as `/api/auth/local` with a password or a social provider, do not ask for a second factor. Disable the ones you do not use (accounts created by Magic Link have no password).
731
+ - **Transport and logs**
732
+ - Every public Magic Link response sends `Cache-Control: no-store` and `Referrer-Policy: no-referrer`.
733
+ - Plugin logs contain no e-mail addresses, tokens or URLs with tokens.
734
+ - **Admin**
735
+ - Permissions are enforced on the server for every route.
736
+ - Impersonation needs its own permission, and so do settings that redirect links or change identity rules (`settings.security`).
737
+ - The license key is masked for readers.
738
+ - **Templates.** Placeholder rendering only; there is no code execution.
992
739
 
993
- **Major UX Improvements**
994
- - 🎨 Complete MFA settings redesign with modern UI
995
- - πŸ’Ž License tier status display with visual indicators
996
- - πŸ”’ Locked feature cards with clear premium/advanced badges
997
- - πŸ‡©πŸ‡ͺ Full German translations for all MFA settings
998
- - βœ… Improved explanations and help texts
999
- - πŸ“§ Better Email OTP configuration section
1000
- - πŸ” Added step-by-step "How it works" guides
740
+ Known limitation: in a cluster, concurrent admin writes to the session list, blocklist or ban list can overwrite each other (see [cluster notes](#running-behind-a-proxy-or-in-a-cluster)).
1001
741
 
1002
- ### v4.15.6 (2025-11-22)
742
+ Report vulnerabilities privately through [GitHub security advisories](https://github.com/Schero94/strapi-plugin-magic-link-v5/security/advisories/new).
1003
743
 
1004
- **Email OTP Configuration**
1005
- - βœ… Added Email OTP configuration section
1006
- - βš™οΈ Code length, expiry time, max attempts settings
1007
- - πŸ”„ Resend cooldown configuration
1008
- - πŸ“§ OTP delivery method selection
1009
- - ℹ️ Improved help text and info boxes
744
+ ## Running behind a proxy or in a cluster
1010
745
 
1011
- ### v4.15.5 (2025-11-22)
746
+ Rate limits and IP bans need the real client IP. Behind nginx, Caddy, Traefik, a load balancer or a CDN, configure `config/server.js`:
1012
747
 
1013
- **Mobile & Settings UI Fixes**
1014
- - πŸ“± Fixed mobile button icon centering issues
1015
- - πŸ—‘οΈ Removed duplicate OTP accordion
1016
- - 🎨 Improved badge colors for better readability
1017
- - ✨ Better contrast for Premium/Advanced badges
1018
-
1019
- For full version history, see [GitHub Releases](https://github.com/Schero94/strapi-plugin-magic-link-v5/releases).
748
+ ```js
749
+ module.exports = ({ env }) => ({
750
+ url: env('PUBLIC_URL', 'https://api.example.com'),
751
+ proxy: { koa: true, maxIpsCount: 1 }, // number of proxies in front of Strapi
752
+ });
753
+ ```
1020
754
 
1021
- ---
755
+ Without `maxIpsCount`, clients can spoof `X-Forwarded-For`. The plugin logs a warning at startup when `proxy.koa` is on and `maxIpsCount` is missing.
1022
756
 
1023
- ## πŸ“„ License
757
+ **IP bans** match like the rate limits: IPv4 (and IPv4-mapped IPv6) exactly, IPv6 per /64 network. Banning one IPv6 address bans its whole /64, and unbanning any address of that /64 removes the ban.
1024
758
 
1025
- This project is licensed under the **MIT License** - see the [LICENSE](LICENSE) file for details.
759
+ **Several instances:**
1026
760
 
1027
- **Important:** While the code is open source, the license validation system must remain intact. This ensures quality, security, and continued development of the plugin.
761
+ - Limits are in memory by default. For shared limits, set `rateLimit.redis` in the plugin config.
762
+ - The session list, the JWT blocklist and the ban list are stored in the plugin store. Writes are serialised within one process only, so concurrent writes from several instances can still overwrite each other (AUTH-14, not solved). In practice: two admins revoking sessions or banning IPs on different instances at the same moment can lose one of the two changes; repeat the action and check the list afterwards. Logins only append session records and are not affected by a lost revocation, but a lost revocation leaves that token valid.
763
+ - Each instance caches the JWT blocklist for up to 2 seconds, so a revocation reaches other instances within that time.
764
+ - Login tokens and OTP codes are single use across instances (conditional database updates).
1028
765
 
1029
- ---
766
+ ## Migrating from strapi-plugin-passwordless
1030
767
 
1031
- ## πŸ’¬ Support & Community
768
+ 1. Install Magic Link and remove `strapi-plugin-passwordless`.
769
+ 2. Enable **Passwordless compatibility** in **Settings > Advanced**.
770
+ 3. Your existing calls to `POST /api/passwordless/send-link` and `GET /api/passwordless/login?loginToken=` keep working. They are rewritten to the Magic Link routes and honour `api.rest.prefix`.
1032
771
 
1033
- ### Need Help?
772
+ When the setting is off, those paths are passed on to Strapi untouched.
1034
773
 
1035
- - πŸ› **Bug Reports**: [GitHub Issues](https://github.com/Schero94/strapi-plugin-magic-link-v5/issues)
1036
- - πŸ’‘ **Feature Requests**: [GitHub Discussions](https://github.com/Schero94/strapi-plugin-magic-link-v5/discussions)
1037
- - πŸ“¦ **npm Package**: [strapi-plugin-magic-link-v5](https://www.npmjs.com/package/strapi-plugin-magic-link-v5)
774
+ ## Troubleshooting
1038
775
 
1039
- ### Resources
776
+ | Symptom | Fix |
777
+ | --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
778
+ | Strapi does not start: "MAGIC_LINK_ENCRYPTION_KEY ..." | Set both secrets (32+ characters) or `MAGIC_LINK_ALLOW_DEV_SECRETS=true` locally |
779
+ | `send-link` says sent but no e-mail arrives | The address has no account (and auto-registration is off), or the e-mail provider failed. Check the Strapi log for `[magic-link]` lines. |
780
+ | Link opens but answers `token.invalid` | The link expired, was already used, was blocked, or the token was cut off by the mail client |
781
+ | `/otp/send` answers 400 "magicLinkToken is required" | Call `/login` first and pass its `loginToken` as `magicLinkToken` |
782
+ | Settings refuse my URL | Use `https://` (http only for `localhost`), and check `allowedLinkOrigins` |
783
+ | Everyone shares one rate limit / bans hit the proxy | Configure `server.proxy` (see above) |
784
+ | 403 with `IP_ADDRESS_BLOCKED` | The IP is banned. Unban it in **Magic Link > IP Bans**. |
785
+ | 400 "Only e-mail delivery is supported" | Remove `phoneNumber`, `phone`, `via` and non-e-mail `delivery` from the request |
786
+ | Users cannot reach `/totp/status` (403) | Grant the **totp** actions to the Authenticated role |
787
+ | Saving settings answers 403 `settings.security.forbidden` | The role lacks **Settings > Security** for the listed fields |
788
+ | Admins see no Magic Link menu after upgrading | Grant **Read** (and **Manage**) to their role; see the upgrade guide |
1040
789
 
1041
- - πŸ“– **Documentation**: This README + [Strapi Docs](https://docs.strapi.io)
1042
- - πŸ’» **Source Code**: [GitHub Repository](https://github.com/Schero94/strapi-plugin-magic-link-v5)
1043
- - 🎯 **Live Example**: Coming soon
1044
- - πŸ“Ή **Video Tutorial**: Coming soon
790
+ ## Upgrading from 5.x to 6.0.0
1045
791
 
1046
- ### Sponsoring
792
+ 6.0.0 is a major release focused on security. Work through this list:
1047
793
 
1048
- If this plugin saves you time and you'd like to support development:
794
+ 1. **Runtime.** Node.js 22+ and Strapi 5.24.0+.
795
+ 2. **Secrets.** Set `MAGIC_LINK_ENCRYPTION_KEY` and `MAGIC_LINK_OTP_PEPPER` (32+ characters each). If your old encryption key was shorter, or you relied on the development fallback, put the old value in `MAGIC_LINK_ENCRYPTION_KEY_PREVIOUS` for the first start. For the old fallback that value is your `ADMIN_JWT_SECRET`.
796
+ 3. **E-mail only.** Remove WhatsApp/SMS fields from `send-link` calls. Delete a leftover `.whatsapp-auth` folder from releases up to 5.2.x.
797
+ 4. **Admin roles.** `access` became `read`, `manage`, `settings.read` and `settings.update`, plus `tokens.issue` for impersonation and `settings.security` for security settings.
798
+ - On startup, roles holding `access` get **Read** and **Manage** automatically (see [role migration](#permissions)).
799
+ - Check each role, and grant **Settings > Read** to roles that used the License page.
800
+ - Grant **Settings > Security** only to roles that may change link targets, templates, account and MFA rules. It is never granted automatically.
801
+ 5. **Frontend.**
802
+ - Exchange the token with `POST /login { loginToken }`.
803
+ - Handle `requiresTOTPSetup` if you use required MFA.
804
+ - Read `expires_at` instead of the removed `expiry_formatted`.
805
+ - Read errors from `error.key` (see [Errors](#errors) for the answers without a key).
806
+ 6. **Templates.** Replace JavaScript in templates with `{{PLACEHOLDERS}}`. Plain `<%= VAR %>` placeholders are converted automatically.
807
+ 7. **URLs.** `frontend_url`, `confirmationUrl` and `callback_url` must be `https` (http only for localhost).
808
+ 8. **Tokens.** `token_length` is now 22 to 128. Unhashed links from very old releases stop working.
809
+ 9. **Authenticators.**
810
+ - Existing enrolments keep working where ownership can be proven. The others are disabled and the users enrol again.
811
+ - Old backup codes are dropped.
812
+ - Grant the **totp** actions to the Authenticated role for self-service.
813
+ 10. **Admin token API.** Send `ttl` (hours) as a top-level field. Creating links without e-mail needs `tokens.issue`. Several unused admin routes were removed.
1049
814
 
1050
- - ⭐ Star the repository on GitHub
1051
- - πŸ› Report bugs and suggest features
1052
- - πŸ’‘ Contribute code improvements
1053
- - πŸ“’ Share with the Strapi community
815
+ The full list with every changed field is in [CHANGELOG.md](CHANGELOG.md#unreleased).
1054
816
 
1055
- ---
817
+ ## Languages, license and support
1056
818
 
1057
- ## Security Advisories & Dependency Overrides
819
+ - **Languages.** The admin panel follows the admin interface language: English, German, French, Spanish or Portuguese, with English as the fallback. Server error messages use the same five languages.
820
+ - **License.** MIT, see [LICENSE](LICENSE). The optional license key in the admin panel unlocks nothing, and every feature works without it. Disable license-server calls with `license.remoteLookup: false` or `MAGIC_LINK_LICENSE_LOOKUP=false`.
821
+ - **Support.** Report bugs and request features through [GitHub issues](https://github.com/Schero94/strapi-plugin-magic-link-v5/issues).
1058
822
 
1059
- This plugin ships with a first-party dependency tree audited on every
1060
- release, but two transitive paths are governed by upstream packages we
1061
- do not control. If your own `npm audit` flags any of the following,
1062
- add the matching entry to your **Strapi project's** `package.json`
1063
- (not this plugin's β€” npm `overrides` only take effect on the
1064
- root project):
1065
-
1066
- ```jsonc
1067
- {
1068
- "overrides": {
1069
- // GHSA-xq3m-2v4x-88gg β€” protobufjs <7.5.5 arbitrary code execution.
1070
- // Reaches us via @whiskeysockets/baileys β†’ libsignal β†’ protobufjs.
1071
- // We already ship this override for local development; apply it in
1072
- // your Strapi project so the fix propagates to the installed tree.
1073
- "protobufjs": "^7.5.5",
1074
-
1075
- // Optional: if you do not use the WhatsApp delivery feature, you
1076
- // can also skip installing @whiskeysockets/baileys altogether:
1077
- // npm install --omit=optional
1078
- // (The Magic-Link code path detects the missing module and
1079
- // disables the WhatsApp controller at runtime β€” the rest of the
1080
- // plugin keeps working.)
1081
- }
1082
- }
1083
- ```
1084
-
1085
- After editing `package.json`, run:
823
+ ### Contributing
1086
824
 
1087
825
  ```bash
1088
- rm -rf node_modules package-lock.json
1089
- npm install
1090
- npm audit
826
+ npm ci --legacy-peer-deps
827
+ npm run lint && npm run format:check && npm test
828
+ npm run build && npm run verify
1091
829
  ```
1092
830
 
1093
- The critical `protobufjs` advisory disappears once the override is in
1094
- place. The remaining `@strapi/design-system β†’ lodash` advisory is a
1095
- Strapi-core issue and will clear up automatically when you upgrade
1096
- `@strapi/strapi` to the latest patch release.
1097
-
1098
- ---
1099
-
1100
- <div align="center">
1101
-
1102
- **Made with ❀️ by [Schero94](https://github.com/Schero94)**
831
+ Commits follow Conventional Commits. Releases are published by semantic-release with npm provenance.
1103
832
 
1104
- *Passwordless authentication, simplified.*
833
+ ### Dependency maintenance
1105
834
 
1106
- [![GitHub](https://img.shields.io/github/stars/Schero94/strapi-plugin-magic-link-v5?style=social)](https://github.com/Schero94/strapi-plugin-magic-link-v5)
1107
- [![npm](https://img.shields.io/npm/dt/strapi-plugin-magic-link-v5)](https://www.npmjs.com/package/strapi-plugin-magic-link-v5)
835
+ - Dependabot opens weekly grouped updates.
836
+ - Patch and minor updates merge automatically once lint, format check, tests, build and verify pass.
837
+ - Major updates wait for review.
838
+ - React, styled-components, react-intl, react-router-dom and `@strapi/*` majors are never proposed, because the Strapi admin host sets those versions.
839
+ - `npm audit` reports GHSA-mrhx-c7xc-m3w5 (zod below 4.6.6, memory exhaustion on oversized collections) through `@strapi/utils` 5.61, which pins zod 4.4.3. Magic Link validates with its own zod 4.6.7. Until Strapi ships a fix, hosts can force the patched zod with npm `overrides`:
1108
840
 
1109
- </div>
841
+ ```json
842
+ { "overrides": { "@strapi/utils": { "zod": "^4.6.6" } } }
843
+ ```