Magic Links
Passwordless email sign-in with Cloudflare Email Service or a custom sender callback
What it gives you
kyzen ships a first-class passwordless email flow:
- request a one-time sign-in link with
requestMagicLink({ formData }) - deliver the email through a Cloudflare
send_emailbinding or your own callback - consume the link with
consumeMagicLink(token) - create a normal session cookie backed by the standard
sessionstable
This keeps the app SSR-first: native form post to request the link, native GET to consume it, no client auth SDK required.
Minimal setup
// src/server/auth-config.ts
import { kyzenAuthConfig } from '@kuratchi/kyzen/adapter';
export const authConfig = kyzenAuthConfig({
cookieName: 'app_session',
sessionEnabled: true,
magicLinks: {
binding: 'DB',
emailBinding: 'EMAIL',
from: 'noreply@example.com',
fromName: 'My App',
appName: 'My App',
linkLabel: 'Sign in to My App',
requestRedirect: '/auth/check-email',
verifyPath: '/auth/verify-email',
signInRedirect: '/',
tokenExpiryMs: 15 * 60 * 1000,
requireExistingUser: true,
},
});
// src/middleware.ts
import { defineMiddleware } from '@kuratchi/koze';
import { kyzenAuthMiddleware } from '@kuratchi/kyzen/middleware';
import { authConfig } from '$server/auth-config';
export default defineMiddleware({
auth: kyzenAuthMiddleware(authConfig),
});
Built-in email delivery
When emailBinding is configured, the auth package can send a branded magic-link email without a custom callback.
Supported options:
| Option | Type | Default | Purpose |
|---|---|---|---|
binding |
string |
'DB' |
D1 binding name for auth state |
emailBinding |
string |
'EMAIL' |
Cloudflare send_email binding name |
from |
string |
env.EMAIL_FROM |
Sender address |
fromName |
string |
— | Optional display name for the sender |
appName |
string |
'your account' |
Product name used in subject/body copy |
subject |
string |
Your {appName} sign in link |
Email subject override |
linkLabel |
string |
'Sign in' |
CTA label in the email body |
requestRedirect |
string |
verifyPath + ?email= |
Where to send the browser after link request |
verifyPath |
string |
'/auth/verify-email' |
Route that receives ?token= |
signInRedirect |
string |
'/' |
App landing page after link consumption |
tokenExpiryMs |
number |
15 * 60 * 1000 |
Link lifetime |
sessionDuration |
number |
30 * 24 * 60 * 60 * 1000 |
Session lifetime |
requireExistingUser |
boolean |
true |
Only issue links for known users |
fallbackToConsoleOnSendFailure |
boolean |
true on localhost-style origins |
Log the link instead of throwing when local email send fails |
Custom email delivery
If you want complete control over the outgoing message, pass sendMagicLinkEmail:
magicLinks: {
binding: 'DB',
verifyPath: '/auth/verify-email',
sendMagicLinkEmail: async ({ email, magicUrl, expiresAt }) => {
await env.EMAIL.send({
to: email,
from: 'noreply@example.com',
subject: 'Your secure sign-in link',
html: `<p><a href="${magicUrl}">Sign in</a></p>`,
text: `Sign in: ${magicUrl}`,
});
},
}
The package still handles token storage, expiry, single use, and session creation. Your callback only owns delivery.
Request the link
// src/server/auth/magic-link.ts
import { requestMagicLink } from '@kuratchi/kyzen';
export async function requestSigninMagicLink({ formData }: { formData: FormData }) {
await requestMagicLink({ formData });
}
<form action={requestSigninMagicLink} method="POST">
<input type="email" name="email" required />
<button type="submit">Send sign-in link</button>
</form>
requestMagicLink() reads formData.email, creates a one-time token, stores only the hash in magicLinks, sends the email, and redirects to requestRedirect.
When requireExistingUser is true, unknown emails do not get a link, but the browser still follows the same redirect. That avoids account enumeration.
Consume the link
// src/routes/auth/verify-email/index.koze
<script>
import { consumeMagicLink } from '@kuratchi/kyzen';
import { searchParams } from '@kuratchi/koze/request';
import { redirect } from '@kuratchi/koze';
const token = searchParams.get('token');
let error: string | null = null;
if (token) {
const result = await consumeMagicLink(token);
if (result.success) {
redirect('/');
}
error = result.error || null;
}
</script>
if (error) {
<p>{error}</p>
}
consumeMagicLink(token):
- hashes the plaintext token
- looks up the matching
magicLinksrow - rejects expired or already-consumed tokens
- resolves the user in single-tenant or org-mode storage
- creates a normal auth session
- marks the link consumed and removes outstanding links for that email
Schema
Magic links add one table to the auth schema:
magicLinks: {
id: 'text primary key not null',
email: 'text not null',
userId: 'text',
organizationId: 'text',
tokenHash: 'text not null unique',
expiresAt: 'timestamp_ms not null',
consumedAt: 'timestamp_ms',
createdAt: 'text default now',
updatedAt: 'text default now',
deletedAt: 'text',
}
If you're using the exported auth schema tables, this is included automatically.
Cloudflare setup
Built-in delivery uses the Workers send_email binding:
{
"send_email": [
{
"name": "EMAIL"
}
]
}
For development:
- local Workers simulation logs outbound email details by default
- if you want real sends during local development, use a remote binding
- if email send fails on a localhost-style origin and
fallbackToConsoleOnSendFailureis enabled, the auth package logs the full magic URL instead of failing the request