Skip to content
v3.0.1 — MCP OAuth, organizations, passkeys, SSR adaptersChangelog
Convex Auth
Esc
↑↓navigate↵open⌘Jpreview
On this page

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.

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>
  );
}

The generated URL is https://<CONVEX_SITE_URL>/api/auth/magic-link/verify?token=...&callbackURL=.... When the user clicks it, auth.addHttpRoutes(http):

  1. Verifies the token.
  2. Creates a user and identity if the email is new (or signs in an existing one).
  3. Issues a session token.
  4. Redirects the browser to callbackURL — or newUserCallbackURL when 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.

Was this page helpful?