Skip to main content

Magic Link & Email-First Login

Magic link is a passwordless sign-in method: the user enters their email address, LumoAuth emails a single-use link, and opening the link and confirming signs them in. LumoAuth also supports an email-first login pattern, where the user enters their email as the first step and the next step (password, magic link, push) is shown. The next step depends only on the organization's settings, never on whether the address has an account.

Use magic link when you want a low-friction passwordless flow (common in consumer apps). Use email-first when you want a single email field before the other sign-in options.


How It Works​

  1. User enters their email on the login page.
  2. LumoAuth emails a secure, time-limited link to that address.
  3. The user opens the link. It shows a Continue signing in page; opening the link alone signs nobody in.
  4. The user confirms with Sign in. The confirm is a POST with a CSRF token. It uses the token, signs the user in and redirects to your app.
  5. The link is invalidated after first use.

The confirm step exists because mail scanners and link previewers fetch links in incoming mail. A plain GET no longer uses up the single-use token.

The email is never required to be registered: the server always responds with "check your inbox" regardless of whether the address exists, which prevents user enumeration.


Configure magic link authentication under Security → Sign-in settings, in the Login flow section:

/orgs/{orgId}/portal/settings/authentication

The Login flow section with the Email-first login and Magic link login switches

SettingDescription
Magic link loginAllow users to request a sign-in link instead of entering a password. Turning it on reveals the two options below.
Hide password login and show the magic link form onlyDisable password login entirely — users must always use a magic link
Link expiryHow long the link is valid, in seconds: 60 to 86400, default 900 (15 minutes)

Click Save changes to apply. Turning Magic link login off also stops links that were already sent. Both the confirm page and the confirm POST check the setting again, and the user is sent back to the sign-in page.

Magic link only mode

When Hide password login and show the magic link form only is enabled, the password field is hidden on the login page. This is mutually exclusive with password authentication — you cannot enable both simultaneously for the same organization.


Login Page Behaviour​

When magic link is enabled, the login page shows an Email me a magic link instead button alongside the password field (or an Email me a magic link button instead of it, in magic-link-only mode). After the user submits their email:

  • They are redirected to a "Check your inbox" confirmation page.
  • The page shows the email address that was used and a hint to check spam.
  • A try again link takes them back to the login page to start over.

Security Considerations​

ConcernMitigation
Link interceptionLinks are single-use and expire after a configurable period
User enumerationThe server always responds with success, whether or not the email is registered
Replay attacksTokens are invalidated immediately after first use
Mail scanners / link previewsOpening the link only shows a confirm page; the token is used by the POST from that page
Magic links turned offLinks already in inboxes stop working as soon as the setting is off
PhishingLinks contain a cryptographically random token — no credentials are transmitted
Email delivery delayToken expiry is configurable to account for delayed delivery

Email-First Login Flow​

The email-first flow presents a single email input as the first step. After the user submits their email, the page shows the sign-in options the organization offers: password, magic link, push when anyone in the organization has an enrolled device, and Sign in with a passkey when passkeys are on. Every address gets the same next step. An unknown address fails at the password step exactly like a wrong password, so the flow does not reveal which addresses have accounts or which factors they use.

Enabling Email-First​

Turn on Email-first login in the Login flow section of Security → Sign-in settings (shown above), then Save changes:

/orgs/{orgId}/portal/settings/authentication

When enabled, the login page shows only an email field initially. The next step is shown after the email is submitted. When off, email and password appear together.

The email-first login page showing only the email field


Email Templates​

Customize the magic link email under Settings → Login page & emails → Email templates (click Customise on the Magic Link row):

/orgs/{orgId}/portal/settings/email-templates

The Email templates page with the Magic Link template

The Magic Link email template supports the following variables:

VariableDescription
{{magic_url}}The sign-in link (required)
{{user_name}}The recipient's display name
{{tenant_name}}Your organization's display name
{{expires_in}}Human-readable expiry (e.g. "15 minutes")

Server Endpoints​

These endpoints are called by LumoAuth's login page. You can also call them directly when building a custom login UI using the SDK.

POST /orgs/{orgId}/magic-link
Content-Type: application/x-www-form-urlencoded

email=user%40example.com&_target_path=%2Fdashboard
ParameterRequiredDescription
emailYesThe user's email address
_target_pathNoPath to redirect to after login (e.g. /dashboard)

Response: an HTML confirmation page is returned. The server always responds with HTTP 200 regardless of whether the email is registered.


Email-first options​

Used by email-first flows to decide what to show after the user submits their email.

POST /orgs/{orgId}/check-email
Content-Type: application/x-www-form-urlencoded

email=user%40example.com

Response: the same for every address in the organization.

{
"exists": true,
"has_push_device": true,
"has_passkey": false,
"magic_link_enabled": true,
"passkey_enabled": true,
"password_enabled": true,
"masked_email": ""
}
FieldTypeDescription
existsbooleanAlways true. Kept for older clients; it says nothing about the address
has_push_devicebooleanAnyone in the organization has an enrolled push device (push initiation answers every address the same way)
has_passkeybooleanAlways false; show Sign in with a passkey when passkey_enabled is true
magic_link_enabled / passkey_enabledbooleanOrganization settings
masked_emailstringAlways empty; show the address the user typed

When has_push_device is true the response also carries push_initiate_url, push_status_url and push_login_url. The endpoint is rate-limited per IP and per target address (429) and CAPTCHA-gated with the login placement (403 with captcha_error).


SDK Integration​

TypeScript / JavaScript​

Use the AuthModule from @lumoauth/client to send magic links and implement email-first flows in custom UIs:

import { AuthModule } from '@lumoauth/client';

const auth = new AuthModule({
baseUrl: 'https://app.lumoauth.dev',
orgId: 'acme-corp',
clientId: 'your-client-id',
});

// Send a magic link
await auth.requestMagicLink({ email: 'user@example.com' });

// Optional: redirect the user to a specific page after clicking the link
await auth.requestMagicLink({
email: 'user@example.com',
redirectUri: 'https://myapp.com/dashboard',
});

// Email-first: `exists` is always true (the answer never depends on the
// address), so always continue to the password / magic-link step.
await auth.checkEmailExists('user@example.com');

React SDK​

The @lumoauth/react SDK provides purpose-built hooks for both flows:

Manages loading/sent/error state for the magic link form:

import { useMagicLink } from '@lumoauth/react';

function MagicLinkForm() {
const [email, setEmail] = useState('');
const { sendMagicLink, isLoading, isSent, error, reset } = useMagicLink();

if (isSent) {
return (
<div>
<p>Check your inbox — we sent a sign-in link to <strong>{email}</strong>.</p>
<button onClick={reset}>Use a different email</button>
</div>
);
}

return (
<form onSubmit={(e) => { e.preventDefault(); sendMagicLink(email); }}>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
required
/>
<button type="submit" disabled={isLoading}>
{isLoading ? 'Sending…' : 'Send sign-in link'}
</button>
{error && <p className="error">{error}</p>}
</form>
);
}

useEmailFirst()​

Implements the email-first check step. The server answers exists: true for every address, so onNewUser is only reached if the request fails; send new users to sign-up from a separate link rather than from this check.

import { useEmailFirst } from '@lumoauth/react';

function EmailStep({
onKnownUser,
onNewUser,
}: {
onKnownUser: (email: string) => void;
onNewUser: (email: string) => void;
}) {
const [email, setEmail] = useState('');
const { checkEmail, isLoading } = useEmailFirst();

async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const exists = await checkEmail(email);
if (exists) {
onKnownUser(email);
} else {
onNewUser(email);
}
}

return (
<form onSubmit={handleSubmit}>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
required
/>
<button type="submit" disabled={isLoading}>
{isLoading ? 'Checking…' : 'Continue'}
</button>
</form>
);
}