Authentication
Magic Links
Passwordless authentication via email — users click a link to sign in without needing a password.
Magic links provide passwordless authentication — users enter their email, receive a link, and click it to sign in. No password to remember, no credentials to manage. This is one of the most user-friendly authentication methods available.
Configuration
Enable magic links in your BanataAuthConfig:
// convex/banataAuth/auth.ts
function buildConfig(): BanataAuthConfig {
return {
appName: "My App",
siteUrl: process.env.SITE_URL!,
secret: process.env.BETTER_AUTH_SECRET!,
authMethods: {
magicLink: true, // Enable magic link authentication
},
emailOptions: {
fromAddress: "MyApp <noreply@myapp.com>",
},
};
}Default Delivery Model
Banata's normal magic-link setup is:
- Configure an active provider in Emails > Providers
- Ensure Magic Auth is enabled in Emails > Configuration
- Customize the
magic-linktemplate in Email Templates - Call
authClient.signIn.magicLink(...)from your frontend
Banata then generates the token, renders the template, and sends the email automatically.
Optional Code Override
If you want to deliver magic links through your own provider code, add email.sendMagicLink:
email: {
sendMagicLink: async ({ email, url, token }) => {
await fetch("https://api.resend.com/emails", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.RESEND_API_KEY}`,
},
body: JSON.stringify({
from: "MyApp <noreply@myapp.com>",
to: email,
subject: "Sign in to MyApp",
html: `<a href="${url}">Sign In</a>`,
}),
});
},
},Override Callback Parameters
The sendMagicLink override receives:
| Parameter | Type | Description |
|---|---|---|
email | string | The recipient's email address |
url | string | The full magic link URL (includes token) |
token | string | The raw verification token |
Note: Unlike
sendVerificationEmail, the magic-link override receivesuser(an object). This is because the user may not exist yet — magic links can create new accounts.
Token Lifetime
Magic link tokens expire after 10 minutes (600 seconds) by default. This is defined in @banata-auth/shared:
// @banata-auth/shared/constants.ts
export const TOKEN_LIFETIMES = {
accessToken: 900, // 15 minutes
session: 604800, // 7 days
magicLink: 600, // 10 minutes
invitation: 604800, // 7 days
};After expiration, the user must request a new magic link.
Client-Side API
Send Magic Link
import { authClient } from "@/lib/auth-client";
const { error } = await authClient.signIn.magicLink({
email: "user@example.com",
callbackURL: "/dashboard", // Where to redirect after sign-in
});
if (error) {
// Handle errors:
// - "Rate limit exceeded" (429)
// - "Invalid email" (422)
console.error(error.message);
} else {
// Show "Check your email" message
}Magic Link Form Example
"use client";
import { authClient } from "@/lib/auth-client";
import { useState } from "react";
export default function MagicLinkPage() {
const [email, setEmail] = useState("");
const [sent, setSent] = useState(false);
const [error, setError] = useState("");
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
const { error } = await authClient.signIn.magicLink({
email,
callbackURL: "/dashboard",
});
if (error) {
setError(error.message ?? "Failed to send magic link");
} else {
setSent(true);
}
}
if (sent) {
return (
<div>
<h1>Check your email</h1>
<p>
We sent a sign-in link to <strong>{email}</strong>.
Click the link in the email to sign in.
</p>
<p style={{ color: "#666" }}>
The link expires in 10 minutes. Check your spam folder if you
don't see it.
</p>
<button onClick={() => setSent(false)}>
Try a different email
</button>
</div>
);
}
return (
<form onSubmit={handleSubmit}>
<h1>Sign in with Magic Link</h1>
<input
type="email"
placeholder="your@email.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
{error && <p style={{ color: "red" }}>{error}</p>}
<button type="submit">Send Magic Link</button>
</form>
);
}How It Works
Here's the complete magic link flow:
1. User enters email → POST /api/auth/sign-in/magic-link
2. Server generates a signed token (valid for 10 minutes)
3. Server uses Banata's configured provider and the `magic-link` template to send the email
4. User receives the email and clicks the sign-in link
6. Browser navigates to /api/auth/magic-link/verify?token=xxx
7. Server verifies the token signature and expiration
8. If no user exists with that email → creates a new user
9. If user exists → creates a new session
10. Browser is redirected to callbackURL with session cookie setNew Users vs. Existing Users
- New email — A new user account is created automatically. No separate sign-up step needed.
- Existing email — The existing user is signed in. Their account is unchanged.
- Linked accounts — If the email matches a user who signed up with email/password or social OAuth, they get signed into that existing account.
Combining with Other Methods
Magic links work well alongside other authentication methods. A common pattern is offering magic links as the primary method with email/password as a fallback:
authMethods: {
emailPassword: true, // Traditional sign-in
magicLink: true, // Passwordless sign-in
organization: true, // Organizations
},
emailOptions: {
fromAddress: "MyApp <noreply@myapp.com>",
},In your UI:
export default function SignInPage() {
const [mode, setMode] = useState<"magic" | "password">("magic");
return (
<div>
<div>
<button onClick={() => setMode("magic")}>Magic Link</button>
<button onClick={() => setMode("password")}>Password</button>
</div>
{mode === "magic" ? (
<MagicLinkForm />
) : (
<PasswordForm />
)}
</div>
);
}Security Considerations
Token Security
- Tokens are cryptographically signed using the
BETTER_AUTH_SECRET. - Tokens are single-use — once verified, they cannot be reused.
- Tokens expire after 10 minutes — a narrow window limits exposure.
- Token verification uses
timingSafeEqual()to prevent timing attacks.
Email Security
- Magic links are as secure as the user's email account. If someone has access to the email, they can sign in.
- For higher-security applications, consider combining magic links with MFA (TOTP) for a passwordless + second-factor experience.
Rate Limiting
Magic link requests are rate-limited to prevent abuse:
| Endpoint | Limit |
|---|---|
| Send magic link | 30 requests per minute (per IP) |
If a user exceeds the rate limit, a RateLimitError (429) is returned.
Email OTP Alternative
If you prefer one-time codes instead of links, Banata Auth also supports Email OTP (one-time password):
authMethods: {
emailOtp: true, // Enable email OTP
},
emailOptions: {
fromAddress: "MyApp <noreply@myapp.com>",
},The client-side flow for OTP:
// Step 1: Send the OTP
await authClient.signIn.emailOtp({
email: "user@example.com",
});
// Step 2: Verify the OTP (user enters the code)
const { data, error } = await authClient.signIn.emailOtp({
email: "user@example.com",
otp: "123456",
});Audit Events
| Event | When |
|---|---|
magic_link.sent | Magic link email sent |
session.created | User successfully signed in via magic link |
user.created | New user created via magic link (first-time sign-in) |
Troubleshooting
"Magic link expired"
The token is only valid for 10 minutes. Ask the user to request a new one. Common causes:
- User took too long to check their email
- Email was delayed by the provider
- User clicked an old link
"Invalid magic link"
The token signature doesn't match. This can happen if:
- The
BETTER_AUTH_SECRETwas changed after the link was generated - The link URL was modified or truncated (some email clients break long URLs)
Magic Link Email Not Received
- Check Emails > Providers for an active provider
- Check Emails > Configuration and make sure Magic Auth is enabled
- Check the
magic-linktemplate in Email Templates - If you overrode delivery in code, check your
sendMagicLinkcallback for errors - Check the spam/junk folder
What's Next
- Multi-Factor Auth — Add TOTP as a second factor
- Email & Password — Traditional authentication
- Social OAuth — Sign in with Google, GitHub, etc.