rtConfig {
provider: 'smtp' | 'gmail' | 'api';
senderAddress: string;
credentials: Record<string, string | number | boolean>;
}
### Step 3: Build the Authentication Manager
Instead of instantiating the library directly in route handlers, wrap it in a manager class. This centralizes initialization, enforces configuration validation, and simplifies testing.
```typescript
// src/auth/MagicLinkManager.ts
import AuthVerify from 'auth-verify';
import { AuthConfig, EmailTransportConfig } from '../config/types';
export class MagicLinkManager {
private instance: AuthVerify;
constructor(config: AuthConfig) {
this.instance = new AuthVerify({
mlSecret: config.secretKey,
mlExpiry: config.tokenExpiry,
appUrl: config.baseUrl,
storeTokens: config.storageBackend
});
}
public configureTransport(transport: EmailTransportConfig): void {
const baseConfig = {
service: transport.provider,
sender: transport.senderAddress,
...transport.credentials
};
this.instance.magic.sender(baseConfig);
}
public async initiateLogin(targetEmail: string, templateOptions?: { subject?: string; html?: string }): Promise<void> {
await this.instance.magic.send(targetEmail, templateOptions || {});
}
public async validateToken(token: string): Promise<Record<string, unknown>> {
return await this.instance.magic.verify(token);
}
}
Architecture Rationale:
- Encapsulation: The
AuthVerify instance is private. External modules interact only through typed methods, preventing accidental reconfiguration.
- Transport Abstraction: Email configuration is decoupled from token logic. This allows swapping SMTP for API providers without touching authentication routes.
- Explicit Expiry: Token lifetime is enforced at initialization. Short windows (5–15 minutes) limit the attack surface if a link is intercepted.
Step 4: Express Route Integration
Separate the request handling from business logic. Use middleware for validation and error normalization.
// src/routes/authRoutes.ts
import { Router, Request, Response } from 'express';
import { MagicLinkManager } from '../auth/MagicLinkManager';
const router = Router();
let authManager: MagicLinkManager;
export const initializeAuthRoutes = (manager: MagicLinkManager) => {
authManager = manager;
return router;
};
router.post('/login/request', async (req: Request, res: Response) => {
const { email } = req.body;
if (!email || typeof email !== 'string') {
res.status(400).json({ error: 'Valid email address is required' });
return;
}
try {
await authManager.initiateLogin(email, {
subject: 'Your secure access link',
html: `<p>Click the button below to sign in. This link expires shortly.</p>
<a href="{{link}}" style="padding: 10px 20px; background: #0055ff; color: white; text-decoration: none; border-radius: 4px;">Access Account</a>`
});
res.json({ status: 'pending', message: 'Check your inbox for the access link' });
} catch (error) {
res.status(500).json({ error: 'Failed to dispatch authentication link' });
}
});
router.get('/login/complete', async (req: Request, res: Response) => {
const token = req.query.token as string;
if (!token) {
res.status(400).json({ error: 'Authentication token missing' });
return;
}
try {
const sessionData = await authManager.validateToken(token);
// In production: establish session, set secure cookie, or return JWT
res.json({ status: 'authenticated', session: sessionData });
} catch (error) {
res.status(401).json({ error: 'Invalid or expired token' });
}
});
Architecture Rationale:
- Idempotent Request Flow: The
/login/request endpoint always returns a generic success message, even if the email doesn't exist. This prevents email enumeration attacks.
- Token Validation Isolation: Verification is handled separately from session establishment. This allows you to swap session mechanisms (cookies, JWT, server-side sessions) without modifying the token validation logic.
- Placeholder Injection: The
{{link}} string is automatically replaced by the underlying library with the full verification URL. No manual string concatenation is required.
Step 5: Application Bootstrap
// src/index.ts
import express from 'express';
import dotenv from 'dotenv';
import { MagicLinkManager } from './auth/MagicLinkManager';
import { initializeAuthRoutes } from './routes/authRoutes';
dotenv.config();
const app = express();
app.use(express.json());
const authConfig = {
secretKey: process.env.AUTH_SECRET || 'fallback-dev-key',
baseUrl: process.env.APP_URL || 'http://localhost:3000',
tokenExpiry: '10m',
storageBackend: (process.env.TOKEN_STORAGE as 'memory' | 'redis') || 'memory'
};
const authManager = new MagicLinkManager(authConfig);
authManager.configureTransport({
provider: 'api',
senderAddress: 'noreply@yourdomain.com',
credentials: {
apiService: 'resend',
apiKey: process.env.RESEND_API_KEY || ''
}
});
app.use('/auth', initializeAuthRoutes(authManager));
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`Authentication service listening on port ${PORT}`));
Pitfall Guide
1. Memory Token Storage in Production
Explanation: The default memory backend stores tokens in the Node.js process heap. In clustered or multi-instance deployments, tokens generated on one instance cannot be verified on another. Memory also grows unbounded until garbage collection or restart.
Fix: Set storeTokens: 'redis' and provide a Redis connection string. Redis provides distributed state, automatic expiration, and sub-millisecond lookup times.
2. Ignoring Token Expiration Windows
Explanation: Long-lived tokens (e.g., 24 hours) increase the window for interception or replay attacks. Users also expect immediate access; delayed clicks often fail silently.
Fix: Use mlExpiry: '5m' to '15m'. Communicate the window clearly in the email template. Implement a fallback flow if users report expired links.
3. Hardcoding Secrets in Source Control
Explanation: The mlSecret value signs and validates tokens. If exposed, attackers can forge valid authentication links.
Fix: Load AUTH_SECRET from environment variables or a secrets manager. Rotate the secret periodically and invalidate existing tokens on rotation.
Explanation: Sending to malformed addresses wastes API quota, triggers bounce penalties, and can degrade sender reputation.
Fix: Validate email syntax before calling initiateLogin. Use a lightweight regex or a dedicated validation library. Reject invalid formats early with a 400 response.
5. Logging Tokens or Full URLs
Explanation: Debug logs, error trackers, or proxy servers may capture the full verification URL. Storing tokens in plaintext logs creates a secondary attack surface.
Fix: Never log req.query.token or the generated URL. Use structured logging that masks sensitive fields. Configure your logging framework to redact query parameters containing token.
6. Missing Rate Limiting on the Send Endpoint
Explanation: Unprotected /login/request endpoints can be abused to flood users with emails, trigger spam filters, or exhaust third-party email API quotas.
Fix: Apply IP-based or email-based rate limiting (e.g., 3 requests per 15 minutes per email). Use middleware like express-rate-limit with a sliding window algorithm.
7. Assuming Email Delivery Equals Authentication
Explanation: Email providers may delay delivery, route to spam, or bounce. Users may click links on different devices or browsers, breaking session continuity.
Fix: Implement delivery tracking (webhooks from Resend/Mailgun/SendGrid). Design the verification flow to be device-agnostic. Consider fallback mechanisms like SMS or authenticator apps for critical accounts.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Single-instance development server | storeTokens: 'memory' | Zero infrastructure overhead, fast iteration | $0 |
| Multi-instance production deployment | storeTokens: 'redis' | Cross-instance token validation, automatic TTL cleanup | ~$10–$20/mo for managed Redis |
| Low volume (<1k emails/day) | SMTP or Gmail transport | No API quota limits, predictable delivery | $0–$6/mo (Gmail Workspace) |
| High volume or transactional focus | API provider (Resend, SendGrid, Mailgun) | Higher deliverability, webhooks, analytics, dedicated IPs | ~$0.10 per 1k emails |
| Strict compliance (HIPAA, SOC2) | Self-hosted SMTP + Redis + audit logging | Full control over data residency and retention | Higher infra & engineering cost |
Configuration Template
// src/config/env.ts
import dotenv from 'dotenv';
dotenv.config();
export const ENV = {
AUTH_SECRET: process.env.AUTH_SECRET || '',
APP_URL: process.env.APP_URL || 'http://localhost:3000',
TOKEN_STORAGE: (process.env.TOKEN_STORAGE as 'memory' | 'redis') || 'memory',
EMAIL_PROVIDER: process.env.EMAIL_PROVIDER || 'resend',
EMAIL_SENDER: process.env.EMAIL_SENDER || 'noreply@yourdomain.com',
EMAIL_API_KEY: process.env.EMAIL_API_KEY || '',
REDIS_URL: process.env.REDIS_URL || 'redis://localhost:6379',
PORT: parseInt(process.env.PORT || '3000', 10)
};
// Validate critical values at startup
if (!ENV.AUTH_SECRET) throw new Error('AUTH_SECRET is required');
if (!ENV.EMAIL_API_KEY) throw new Error('EMAIL_API_KEY is required');
// src/server.ts
import express from 'express';
import { MagicLinkManager } from './auth/MagicLinkManager';
import { initializeAuthRoutes } from './routes/authRoutes';
import { ENV } from './config/env';
const app = express();
app.use(express.json());
const authManager = new MagicLinkManager({
secretKey: ENV.AUTH_SECRET,
baseUrl: ENV.APP_URL,
tokenExpiry: '10m',
storageBackend: ENV.TOKEN_STORAGE
});
authManager.configureTransport({
provider: 'api',
senderAddress: ENV.EMAIL_SENDER,
credentials: {
apiService: ENV.EMAIL_PROVIDER,
apiKey: ENV.EMAIL_API_KEY
}
});
app.use('/auth', initializeAuthRoutes(authManager));
app.listen(ENV.PORT, () => {
console.log(`Auth service ready on port ${ENV.PORT}`);
});
Quick Start Guide
- Initialize the project: Run
npm install express auth-verify dotenv typescript @types/express @types/node ts-node and generate tsconfig.json.
- Set environment variables: Create a
.env file with AUTH_SECRET, APP_URL, EMAIL_PROVIDER, EMAIL_SENDER, and EMAIL_API_KEY.
- Copy the configuration template: Place
env.ts, MagicLinkManager.ts, authRoutes.ts, and server.ts in their respective directories.
- Start the server: Run
npx ts-node src/server.ts. The service will listen on port 3000.
- Test the flow: Send a POST request to
http://localhost:3000/auth/login/request with {"email": "test@example.com"}. Check the inbox, click the link, and verify the GET response at /auth/login/complete.
This architecture delivers a secure, scalable, and maintainable passwordless authentication system. By isolating token lifecycle management, enforcing strict configuration boundaries, and preparing for distributed deployment from day one, you eliminate the most common failure modes while keeping the implementation lean and auditable.