Package Exports
- @moikapy/magic-link
- @moikapy/magic-link/next
- @moikapy/magic-link/react
Readme
@moikapy/magic-link
Email magic link authentication. Zero passwords. Edge-first.
Currently supports Cloudflare D1. Other storage backends are on the roadmap (see below).
Install
npm install @moikapy/magic-link
# or
bun add @moikapy/magic-linkQuick Start
1. Run migration
wrangler d1 execute scholar-db --remote --file=node_modules/@moikapy/magic-link/migrations/0001_magic_auth.sql2. Set up env vars
wrangler secret put RESEND_API_KEY
wrangler secret put OPENROUTER_ENCRYPT_KEY # if storing API keys3. Create API routes
// src/app/api/auth/magic/send/route.ts
import { sendMagicLink, validateOrigin } from "@moikapy/magic-link/next";
export async function POST(request: Request) {
// CSRF protection (SEC-007)
if (!validateOrigin(request)) {
return Response.json({ error: "Invalid origin" }, { status: 403 });
}
const { email } = await request.json();
const result = await sendMagicLink(email);
// Always returns { ok: true } — prevents email enumeration
return Response.json(result);
}// src/app/api/auth/magic/verify/route.ts
import { verifyMagicToken, setSessionCookie, VERIFY_SECURITY_HEADERS } from "@moikapy/magic-link/next";
export async function GET(request: Request) {
const token = new URL(request.url).searchParams.get("token");
if (!token) {
return Response.json(
{ ok: false, error: "invalid_token" },
{ status: 400, headers: VERIFY_SECURITY_HEADERS }
);
}
const result = await verifyMagicToken(token);
if (!result.ok) {
return Response.json(result, { status: 400, headers: VERIFY_SECURITY_HEADERS });
}
// Set secure session cookie
setSessionCookie(result.sessionId);
// IMPORTANT: Use a hardcoded redirect URL, never user-controlled input (SEC-004)
return Response.redirect("https://scholar.moikapy.dev/bible");
}// src/app/api/auth/session/route.ts
import { getSession } from "@moikapy/magic-link/next";
export async function GET() {
const { user } = await getSession();
return Response.json({ user });
}// src/app/api/auth/logout/route.ts
import { deleteSession } from "@moikapy/magic-link/next";
export async function POST() {
await deleteSession();
return Response.json({ ok: true });
}4. Create verify page
// src/app/auth/verify/page.tsx
import { MagicLinkVerify } from "@moikapy/magic-link/react";
export default function VerifyPage() {
return (
<div className="min-h-screen flex items-center justify-center">
<MagicLinkVerify redirectUrl="/bible" />
</div>
);
}5. Add login component
import { MagicLinkAuth } from "@moikapy/magic-link/react";
export default function LoginPage() {
return <MagicLinkAuth appName="Scholar" redirectUrl="/bible" />;
}6. Store OpenRouter API key against user identity
// After OpenRouter OAuth callback — store key in D1, not cookie
import { storeApiKey, getApiKey } from "@moikapy/magic-link/next";
// Store (replaces httpOnly cookie approach)
await storeApiKey(openRouterApiKey);
// Retrieve (reads from D1, encrypted with AES-256-GCM)
const apiKey = await getApiKey();Security
This package was designed with security best practices:
| Property | Implementation |
|---|---|
| Token hashing | SHA-256 hash stored in DB; raw token never in DB |
| Single-use tokens | Immediately marked as used_at on verification |
| Token expiry | 15 minutes by default (configurable) |
| Email enumeration prevention | Always returns { ok: true }, constant-time delay for invalid emails |
| Session fixation | New session ID always generated; no reuse |
| Cookie security | httpOnly, Secure, SameSite=Lax, 30-day maxAge |
| API key encryption | AES-256-GCM at rest (reuses @moikapy/openrouter-auth/crypto) |
| Key rotation | Supports OPENROUTER_ENCRYPT_KEY_PREVIOUS for seamless rotation |
| CSRF protection | Origin validation via validateOrigin() helper |
| Referrer leakage | Token stripped from URL after verify, Referrer-Policy: no-referrer headers |
| Search engine exposure | X-Robots-Tag: noindex on verify endpoint |
Rate limiting (required)
This package does not implement rate limiting. You MUST rate-limit the /api/auth/magic/send endpoint.
Cloudflare Rate Limiting rules (recommended):
- 3 emails per address per 15 minutes
- 10 requests per IP per hour
Or use the validateSend() hook in config:
const result = await sendMagicLink(email, {
...config,
validateSend: async (email) => {
// Check D1 for recent sends to this email
const recent = await db.prepare(
"SELECT COUNT(*) as count FROM magic_tokens WHERE email = ? AND created_at > ?"
).bind(email, Math.floor(Date.now() / 1000) - 900).first();
if (recent?.count >= 3) throw new Error("Too many requests");
},
});Redirect security (SEC-004)
Never use user-controlled input for redirects after verification:
// ❌ UNSAFE — attacker could control Host header
return Response.redirect(new URL("/", request.url));
// ✅ SAFE — hardcoded base URL
return Response.redirect("https://scholar.moikapy.dev/bible");API
Core (@moikapy/magic-link)
| Function | Description |
|---|---|
sendMagicLink(email, config) |
Generate token, store hash, send email |
verifyMagicToken(token, config) |
Verify token, create/find user, create session |
getSession(sessionId, config) |
Get user from session ID |
deleteSession(sessionId, config) |
Delete session (logout) |
storeUserApiKey(userId, apiKey, config) |
Encrypt and store API key in D1 |
getUserApiKey(userId, config) |
Decrypt and return stored API key |
Next.js (@moikapy/magic-link/next)
| Function | Description |
|---|---|
sendMagicLink(email, config?) |
D1-aware, auto-resolves from env |
verifyMagicToken(token, config?) |
D1-aware, auto-resolves from env |
getSession(config?) |
Read session from cookie + D1 |
deleteSession(config?) |
Delete session + clear cookie |
storeApiKey(apiKey, config?) |
Encrypt + store key against current user |
getApiKey(config?) |
Decrypt + return key for current user |
getCurrentUser(config?) |
Get current user from session |
setSessionCookie(sessionId, maxAge?) |
Set secure session cookie |
validateOrigin(request, allowedOrigins?) |
CSRF origin validation |
VERIFY_SECURITY_HEADERS |
Security headers for verify response |
React (@moikapy/magic-link/react)
| Component | Description |
|---|---|
<MagicLinkAuth> |
Email input → send → "check your email" flow |
<MagicLinkVerify> |
Token verification → redirect flow |
useMagicLink() |
Hook: { user, loading, logout } |
Migration from Device ID
If you have existing users with device IDs (scholar_uid cookie):
// On magic link login, migrate device data to real user
import { migrateDeviceUser } from "@moikapy/magic-link/db";
// In your verify route, after creating the session:
const deviceId = request.cookies.get("scholar_uid")?.value;
if (deviceId && deviceId !== "anonymous") {
await migrateDeviceUser(db, deviceId, result.user.id);
// Optionally delete the old device ID cookie
}Roadmap
v0.2 — Storage Adapters
Problem: Currently hardcoded to Cloudflare D1. Won't work with PostgreSQL, Turso, Supabase, Planetscale, or any other database.
Solution: Introduce a StorageAdapter interface so consumers can plug any database:
interface StorageAdapter {
// Users
findUserByEmail(email: string): Promise<User | null>;
findUserById(id: string): Promise<User | null>;
createUser(user: { id: string; email: string; displayName?: string }): Promise<User>;
storeEncryptedApiKey(userId: string, encrypted: string): Promise<void>;
getEncryptedApiKey(userId: string): Promise<string | null>;
// Magic tokens
storeToken(email: string, tokenHash: string, expiresAt: number): Promise<void>;
findValidToken(tokenHash: string, now: number): Promise<MagicTokenRow | null>;
markTokenUsed(tokenHash: string): Promise<void>;
// Sessions
createSession(sessionId: string, userId: string, expiresAt: number): Promise<void>;
findSession(sessionId: string, now: number): Promise<SessionRow | null>;
deleteSession(sessionId: string): Promise<void>;
}Planned adapters:
@moikapy/magic-link/adapter-d1(current behavior, extracted)@moikapy/magic-link/adapter-postgres(Drizzle + pg)@moikapy/magic-link/adapter-turso(LibSQL)@moikapy/magic-link/adapter-sqlite(better-sqlite3 for Node.js)
The MagicLinkConfig.db field becomes MagicLinkConfig.storage: StorageAdapter, and the D1-specific code moves to the adapter. Core stays edge-safe.
v0.3 — Framework Adapters
@moikapy/magic-link/express(Express.js middleware + session cookie)@moikapy/magic-link/hono(Hono middleware for any JS runtime)@moikapy/magic-link/remix(Remix auth helpers)
Each framework adapter handles cookie management and D1/env resolution for that platform.
v0.4 — Email Providers
Currently Resend-only. Add pluggable email providers:
interface EmailAdapter {
send(params: { to: string; subject: string; html: string; text: string; from: string; fromName: string }): Promise<{ id: string }>;
}Planned:
@moikapy/magic-link/email-resend(current, extracted)@moikapy/magic-link/email-sendgrid@moikapy/magic-link/email-postmark@moikapy/magic-link/email-smtp(generic, for self-hosted)
v0.5 — OAuth Providers
Add social login alongside magic links:
- Google OAuth
- GitHub OAuth
- Apple Sign-In
These compose with magic link — same StorageAdapter, same session, just different verification paths.
License
MIT