Magic links
Password-less email sign-in with convex-auth.
convex-auth can sign users in with a one-time link sent to their email. No password is needed.
Enable
import { components } from "./_generated/api";
import { convexAuth } from "@vortex-api/convex-auth/convex";
const siteUrl = process.env.CONVEX_SITE_URL?.replace(/\/$/, "");
export const auth = convexAuth({
component: components.convexAuth,
magicLink: {
enabled: true,
appOrigin: siteUrl,
expiresInMs: 5 * 60 * 1000,
sendMagicLink: async ({ email, url, token, name, metadata }) => {
// production: call Resend/Postmark/SES
console.log("Magic link", { email, url, token, name, metadata });
return "email-id";
},
},
});
sendMagicLink receives email, url, token, name?, and metadata?. It must return an id or any truthy string.
Request a link from React
import { useAuthActions } from "@vortex-api/convex-auth/react";
export function SignInWithMagicLink() {
const { signInWithMagicLink, isLoading } = useAuthActions();
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const formData = new FormData(event.currentTarget);
await signInWithMagicLink({
email: formData.get("email") as string,
name: formData.get("name") as string,
callbackURL: "/dashboard",
newUserCallbackURL: "/welcome",
errorCallbackURL: "/auth/error",
metadata: { source: "landing" },
});
}
return (
<form onSubmit={handleSubmit}>
<input name="email" type="email" required />
<input name="name" placeholder="Name (optional)" />
<button type="submit" disabled={isLoading}>
{isLoading ? "Sending..." : "Send me a link"}
</button>
</form>
);
}
How the link works
The generated URL is https://<CONVEX_SITE_URL>/api/auth/magic-link/verify?token=...&callbackURL=.... When the user clicks it, auth.addHttpRoutes(http):
- Verifies the token.
- Creates a user and identity if the email is new (or signs in an existing one).
- Issues a session token.
- Redirects the browser to
callbackURL— ornewUserCallbackURLwhen the link created a brand-new account — carrying?token=...&refreshToken=...&sessionId=...&landingVerifier=....
You do not need a callback page. ConvexAuthProvider ingests the session triple from the URL automatically on mount and strips the credentials from the address bar. updateSession only takes { refreshToken } for rotation — the provider calls it for you.
The landingVerifier param binds the landing to the browser that requested the link. A magic link opened in a different browser or device than the one that requested it is rejected — that’s the point (it stops login-CSRF via forwarded links), but if your product depends on request-on-desktop-open-on-phone flows, pass requireLandingVerifier={false} to ConvexAuthProvider.
All three redirect params — callbackURL, newUserCallbackURL, errorCallbackURL — are validated at verify time: each must be relative (resolved against SITE_URL) or an absolute URL on the Convex site origin, SITE_URL, or a trustedOrigins entry. An untrusted absolute URL fails the request instead of exfiltrating the session.
Options
| Option | Type | Default | Description |
|---|---|---|---|
enabled |
boolean |
true |
Enable magic-link sign-in. |
appOrigin |
string |
— | Origin used in the magic-link URL. |
sendMagicLink |
function |
Required | Send the magic link email. |
expiresInMs |
number |
5 min | Token lifetime. |
disableSignUp |
boolean |
false |
Prevent magic links from creating new accounts. |
sessionTtlMs |
number |
7 days | Session lifetime after sign-in. |
refreshTokenTtlMs |
number |
30 days | Refresh-token lifetime. |