Passwordless authentication for Vendure 3.6+ via emailed magic links.
- Peppered SHA-256 token hashes — only the hash is stored at rest. The pepper is mixed into the SHA-256 input; rotating it invalidates every outstanding token in one move.
- Per-IP + per-email Redis rate limiting — anti-spray on issuance, plus per-email throttle (default 3/hr) so an attacker can't drain a user's link by enumerating.
- Anti-probe responses — the issuance endpoint returns the same shape whether the email is registered, unregistered, or rate-limited, so the API doesn't leak user enumeration.
- Hourly cleanup task —
ScheduledTaskthat deletes expired or used tokens. Registered automatically via the plugin'sconfigurationhook. - Email template — ships with a Handlebars body template that integrates
with
@vendure/email-plugin(you provide the template registration).
yarn add @harder-labs/vendure-plugin-magic-link
# peers:
yarn add @vendure/core @vendure/email-pluginimport { MagicLinkPlugin, magicLinkEmailHandler } from '@harder-labs/vendure-plugin-magic-link';
import { EmailPlugin, defaultEmailHandlers } from '@vendure/email-plugin';
const config: VendureConfig = {
plugins: [
EmailPlugin.init({
handlers: [...defaultEmailHandlers, magicLinkEmailHandler],
// ...
}),
MagicLinkPlugin.init({
pepper: process.env.MAGIC_LINK_PEPPER!, // MUST be >= 16 chars
storefrontBaseUrl: process.env.PUBLIC_STOREFRONT_URL!,
}),
],
};init() throws if pepper.length < 16 or storefrontBaseUrl is missing.
| Var | Required? | Notes |
|---|---|---|
MAGIC_LINK_PEPPER |
yes | ≥16 chars. Production reads from Secrets Manager. Rotating it invalidates all outstanding tokens. |
PUBLIC_STOREFRONT_URL |
yes | Storefront base URL — the magic-link points at ${storefrontBaseUrl}/auth/verify?token=... |
REDIS_HOST, REDIS_PORT |
optional | Defaults to localhost:6379. Used by MagicLinkRateLimitService. |
Shop API:
extend type Mutation {
requestMagicLink(input: RequestMagicLinkInput!): RequestMagicLinkResult!
}The response shape is intentionally identical for success / unknown email /
rate-limited so the API doesn't leak enumeration. Use the existing
authenticate(input: { magicLink: { token: "..." } }) mutation to redeem.
Extracted from the vendure-platform
in-tree implementation (Phase 0 tasks #8 + #14). Includes the timezone-bug
fix (timestamptz on expiresAt) and the per-IP/per-email rate limit
strengthening that came out of the pen-test pass.