Email code
Passwordless sign-in with a 6-digit code emailed to the user. The simplest way to authenticate with Radon.
The user types their email, receives a 6-digit code, and types it back. No password to remember, no link to click. It's the fastest passwordless flow to ship and the one used in the Quickstart.
1. Enable the provider
Email code needs a sender to deliver the code.
import { Radon } from "@radonsdk/auth";
import { postgresAdapter } from "@radonsdk/auth/adapters/postgres";
import { resendSender } from "@radonsdk/auth/senders/resend";
const sender = resendSender({ apiKey: process.env.RESEND_API_KEY!, from: "Acme <auth@acme.com>" });
export const auth = new Radon({
adapter: postgresAdapter(pool),
session: { secret: process.env.RADON_SECRET! },
appName: "Acme",
providers: {
emailCode: { sender },
},
});| Option | Type | Description |
|---|---|---|
sender | EmailSender | Required. Delivers the code email. |
from | string | Overrides the sender's default From for these emails. |
template | CodeTemplate | Custom subject/HTML/text. See below. |
Code length, lifetime, and max attempts are configured globally with the
code option (default: 6
digits, 10-minute lifetime, 5 attempts).
2. Use it over HTTP
Once you've mounted a framework integration, these routes
exist under your base path (e.g. /api/auth):
| Method & path | Body | Effect |
|---|---|---|
POST /email-code/send | { email } | Emails a 6-digit code |
POST /email-code/verify | { email, code } | Signs in → sets session cookie |
// Step 1 — send the code
await fetch("/api/auth/email-code/send", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ email }),
});
// Step 2 — verify what the user typed; on success a session cookie is set
const res = await fetch("/api/auth/email-code/verify", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ email, code }),
});
// res.ok === true → the browser now holds an HttpOnly `radon_session` cookie3. Or call it directly in code
Every provider is callable without the HTTP layer — useful for server actions, scripts, or custom flows.
// Issue + email a code
const { expiresAt } = await auth.emailCode.sendCode({ email: "sam@acme.com" });
// Verify it → resolves (or creates) the user
const { user, created } = await auth.emailCode.verify({
email: "sam@acme.com",
code: "123456",
});
// `created` is true if this was a brand-new signup.
// Issue your own session token if you're not using an HTTP integration:
const { token } = auth.createSessionToken(user.id);Return shapes
sendCode({ email, metadata? }) → { expiresAt: Date }.
verify({ email, code }) → { user: RadonUser, created: boolean }.
Custom email
Pass a template function to control the subject and body. It receives the code
and expiry and returns the email content:
providers: {
emailCode: {
sender,
template: ({ code, expiresInMinutes, appName }) => ({
subject: `${code} is your ${appName} code`,
html: `<h1>${code}</h1><p>Expires in ${expiresInMinutes} minutes.</p>`,
text: `Your ${appName} code is ${code}. Expires in ${expiresInMinutes} minutes.`,
}),
},
}Errors
Verification can fail for a few well-defined reasons. Over HTTP these map to status codes; in code they're typed errors you can catch.
| Error / code | HTTP | Meaning |
|---|---|---|
CodeInvalidError / code_invalid | 401 | Wrong code. Carries attemptsRemaining. |
CodeExpiredError / code_expired | 401 | Code older than its TTL. |
CodeNotFoundError / code_not_found | 401 | No active code for that email. |
MaxAttemptsError / max_attempts_exceeded | 401 | Too many wrong tries — request a new code. |
RateLimitError / rate_limit_exceeded | 429 | Too many sends. Carries retryAfterMs. |
import { CodeInvalidError, MaxAttemptsError } from "@radonsdk/auth";
try {
await auth.emailCode.verify({ email, code });
} catch (err) {
if (err instanceof CodeInvalidError) {
return `Wrong code — ${err.attemptsRemaining ?? 0} tries left`;
}
if (err instanceof MaxAttemptsError) {
return "Too many attempts. Request a new code.";
}
throw err;
}