Magic Links | Kyzen | Primitives Docs

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_email binding or your own callback
  • consume the link with consumeMagicLink(token)
  • create a normal session cookie backed by the standard sessions table

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.

// 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.

// 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):

  1. hashes the plaintext token
  2. looks up the matching magicLinks row
  3. rejects expired or already-consumed tokens
  4. resolves the user in single-tenant or org-mode storage
  5. creates a normal auth session
  6. 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 fallbackToConsoleOnSendFailure is enabled, the auth package logs the full magic URL instead of failing the request