Magic Link
Passwordless email-link sign in for hosted, headless, and SDK flows.
Magic links let users sign in from a short-lived email link. They use the same AuthServer user, client, organization, email branding, transactional email, session, and audit infrastructure as Email OTP.
Magic-link sign-in is off by default. Adding SqlOS does not change password sign-in and does not require an email provider; the feature appears only after magic_link is explicitly added to the credential list.
Magic links are a first-factor local credential. If tenant policy requires TOTP, SqlOS still returns the normal MFA challenge after the link is completed.
Add magic_link to the AuthPage credential list:
builder.AddSqlOS<AppDbContext>(options =>
{
options.AuthServer.SeedAuthPage(page =>
{
page.EnabledCredentialTypes = ["magic_link"];
page.EnablePasswordSignup = false;
});
});To add magic links without removing password fallback:
options.AuthServer.SeedAuthPage(page =>
{
page.EnabledCredentialTypes = ["magic_link", "password"];
page.EnablePasswordSignup = false;
});Hosted AuthPage exposes:
| Endpoint | Purpose |
|---|---|
GET /sqlos/auth/login/magic-link | Render the hosted magic-link request form |
POST /sqlos/auth/login/magic-link/start | Create and send the link |
GET /sqlos/auth/login/magic-link/complete?token=... | Render a confirmation page without consuming the token |
POST /sqlos/auth/login/magic-link/complete | Consume the token and finish sign-in |
The GET /complete route is intentionally passive. It renders a form that posts the token to POST /complete, which consumes the token, validates client or authorization-request binding, issues the session or OAuth redirect, and then handles organization selection or MFA as needed.
Drive magic-link sign-in with @sqlos/headless (flow.magicLink.start / flow.magicLink.complete). The routes below are the HTTP wire contract; client code should use the package. See Build your own login and signup UI.
Headless views are magic-link and magic-link-sent. The hosted AuthPage confirm screen is not a headless view — after the user opens the emailed link, complete with flow.magicLink.complete({ token }).
| Endpoint | Purpose |
|---|---|
POST /sqlos/auth/headless/magic-link/start | Start a magic-link sign-in for an authorization request |
POST /sqlos/auth/headless/magic-link/complete | Consume the emailed token and complete the authorization request |
await flow.magicLink.start({ email });
// view becomes magic-link-sent
await flow.magicLink.complete({ token });
if (flow.status === "redirect" && flow.redirectUrl) {
window.location.assign(flow.redirectUrl);
}The flow holds requestId. Wire request shapes:
{
"requestId": "sar_...",
"email": "jane@example.com"
}{
"requestId": "sar_...",
"token": "..."
}SqlOS runs home realm discovery before creating the link. If the email must use SSO, start sets status === "redirect" to the identity provider instead of sending a local link. Always leave with window.location.assign(redirectUrl).
Backend developers can use SqlOSAuthService directly with a first-party client; SqlOS refuses any other client with invalid_client before it sends a link:
await authService.RequestMagicLinkAsync(
new SqlOSMagicLinkStartRequest(
Email: "jane@example.com",
ClientId: "web",
OrganizationId: null),
httpContext,
ct);The start response is intentionally generic for known and unknown addresses. Complete the link with:
var login = await authService.CompleteMagicLinkAsync(
new SqlOSMagicLinkCompleteRequest(token),
httpContext,
ct);SqlOS creates the built-in auth.magic-link transactional template automatically. It uses Email Branding, and rendered bodies are suppressed in delivery history because they contain token-bearing links.
The default link authority comes from AuthServer.PublicOrigin, or from the configured issuer when PublicOrigin is omitted. SqlOS never builds an emailed link from the incoming Host header. Set PublicOrigin during production readiness when the public address differs from the issuer host.
The shared example keeps magic links disabled by default. Set SqlOS:EnableMagicLink=true in the example API only when you want to exercise its hosted, headless, and direct API flows with a configured or test email sender.
For complete control, configure a message builder:
options.AuthServer.ConfigureMagicLink(link =>
{
link.ApplicationName = "Acme";
link.BuildMessage = ctx => new SqlOSAuthEmailMessage(
ctx.Email,
$"Sign in to {ctx.ApplicationName}",
$"<p><a href=\"{ctx.LoginUrl}\">Sign in</a></p>",
$"Sign in: {ctx.LoginUrl}");
});Magic-link tokens are stored only as hashes in SqlOSTemporaryTokens. The token payload binds each link to the normalized email, user email id when one exists, client id, optional organization id, optional authorization request id, source IP, user agent, and sent status.
A link for an existing account is sent only to the address stored on that account, never to the typed spelling, and completing it requires that bound email row to still carry the same address. Accounts are matched with SqlOS's canonical email key, so a look-alike address never selects another account. Completing a link for an account whose email is still unverified claims the address and revokes the credentials attached before it.
options.AuthServer.ConfigureMagicLink(link =>
{
link.TokenLifetime = TimeSpan.FromMinutes(10);
link.ResendCooldown = TimeSpan.FromSeconds(30);
link.RateLimitWindow = TimeSpan.FromHours(1);
link.MaxLinksPerEmailPerWindow = 5;
link.MaxLinksPerIpPerWindow = 60;
link.MaxLinksPerClientPerWindow = 300;
});Audit events include magic_link.requested, magic_link.completed, magic_link.rejected, magic_link.rate_limit_rejected, and magic_link.send_failed.