# Magic Links

> Passwordless email sign-in with Cloudflare Email Service or a custom sender callback

Package: Kyzen
Canonical: https://kuratchi.dev/docs/kyzen/magic-links
Markdown: https://kuratchi.dev/docs/kyzen/magic-links.md

## 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

```ts
// 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,
  },
});
```

```ts
// 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`:

```ts
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

```ts
// src/server/auth/magic-link.ts
import { requestMagicLink } from '@kuratchi/kyzen';

export async function requestSigninMagicLink({ formData }: { formData: FormData }) {
  await requestMagicLink({ formData });
}
```

```html
<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

```ts
// 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:

```ts
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:

```jsonc
{
  "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

## Read next

- [Getting Started](/docs/kyzen/getting-started)
- [Credentials and Sessions](/docs/kyzen/credentials-and-sessions)
- [Organizations and Schema](/docs/kyzen/organizations-and-schema)
