Magic link sign-in

A sign-in link that says which device asked for it.

Get an API key

Authentication · 6 placeholders

The job

A magic link replaces the password, which means the email is the login. It has to arrive fast, work once, and expire soon enough that a stale inbox is not an open session.

How this one works

Naming the device and browser that asked costs one row and does the work that a password prompt used to do: it lets the reader notice that they are not the one signing in.

What you need

A key, a domain, and the two or three things only your app knows. Nothing here takes longer than the email did to read.

When it sends

When somebody submits their address on the sign-in form. Rate limit per address and per IP, because this endpoint sends mail to anyone who types an address into it.

01

An emails.sh API key

Created in the dashboard, starting with esh_. It stays on your server.

02

A sending domain

Verified with three DNS records, or send from onboarding@emails.sh until yours is ready.

03

A single-use login token

Short expiry, consumed on first use, tied to the browser that asked where you can.

04

The request context

Device and browser, read from the user agent on the sign-in request.

The email

Table layout, inline styles, no stylesheet, no web font, no JavaScript. It renders the same in Outlook, Gmail, and Apple Mail, and the plain-text version goes with it every time.

Subject
Your sign-in link for {{PRODUCT_NAME}}
HTML
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="x-ua-compatible" content="ie=edge">
<!-- Stops iOS Mail resizing the text on its own. -->
<meta name="x-apple-disable-message-reformatting">
<title>Sign in</title>
</head>
<body style="margin:0;padding:0;width:100%;background-color:#f4f4f5;">
<div style="display:none;max-height:0;max-width:0;opacity:0;overflow:hidden;mso-hide:all;font-size:1px;line-height:1px;color:#f4f4f5;">The link expires in {{EXPIRY_MINUTES}} minutes.&#8199;&#65279;&#8199;&#65279;&#8199;&#65279;&#8199;&#65279;&#8199;&#65279;&#8199;&#65279;&#8199;&#65279;&#8199;&#65279;&#8199;&#65279;&#8199;&#65279;&#8199;&#65279;&#8199;&#65279;</div>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="width:100%;background-color:#f4f4f5;">
<tr><td align="center" style="padding:24px 12px;">
<table role="presentation" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:100%;background-color:#ffffff;border:1px solid #e4e4e7;">
<tr><td style="padding:32px;">
<h1 style="margin:0 0 16px;font-family:Helvetica,Arial,sans-serif;font-size:21px;line-height:28px;font-weight:600;color:#18181b;">Sign in to {{PRODUCT_NAME}}</h1>
<p style="margin:0 0 16px;font-family:Helvetica,Arial,sans-serif;font-size:15px;line-height:24px;mso-line-height-rule:exactly;color:#3f3f46;">Here is the link you asked for. It signs you in once and then stops working, and it expires in {{EXPIRY_MINUTES}} minutes.</p>
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 20px;"><tr><td bgcolor="#18181b" style="background-color:#18181b;"><a href="{{LOGIN_URL}}" style="display:inline-block;padding:13px 24px;font-family:Helvetica,Arial,sans-serif;font-size:15px;line-height:20px;font-weight:600;color:#ffffff;text-decoration:none;">Sign in</a></td></tr></table>
<p style="margin:0 0 12px;font-family:Helvetica,Arial,sans-serif;font-size:13px;line-height:20px;mso-line-height-rule:exactly;color:#71717a;">Button not working? Paste this into your browser:<br><a href="{{LOGIN_URL}}" style="color:#3f3f46;text-decoration:underline;word-break:break-all;">{{LOGIN_URL}}</a></p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 20px;"><tr><td height="1" style="height:1px;background-color:#e4e4e7;font-size:0;line-height:0;">&nbsp;</td></tr></table>
<p style="margin:0 0 16px;font-family:Helvetica,Arial,sans-serif;font-size:15px;line-height:24px;mso-line-height-rule:exactly;color:#3f3f46;">Or type this code into the tab you started from:</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 20px;"><tr><td bgcolor="#f4f4f5" style="background-color:#f4f4f5;border:1px solid #e4e4e7;padding:16px;text-align:center;font-family:Consolas,Menlo,Courier,monospace;font-size:26px;line-height:32px;letter-spacing:6px;color:#18181b;">{{LOGIN_CODE}}</td></tr></table>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 20px;border-top:1px solid #e4e4e7;"><tr><td style="padding:10px 0;border-bottom:1px solid #e4e4e7;font-family:Helvetica,Arial,sans-serif;font-size:14px;line-height:20px;color:#71717a;" width="45%">Requested from</td><td align="right" style="padding:10px 0;border-bottom:1px solid #e4e4e7;font-family:Helvetica,Arial,sans-serif;font-size:14px;line-height:20px;color:#18181b;font-weight:600;">{{DEVICE}}</td></tr><tr><td style="padding:10px 0;border-bottom:1px solid #e4e4e7;font-family:Helvetica,Arial,sans-serif;font-size:14px;line-height:20px;color:#71717a;" width="45%">Requested at</td><td align="right" style="padding:10px 0;border-bottom:1px solid #e4e4e7;font-family:Helvetica,Arial,sans-serif;font-size:14px;line-height:20px;color:#18181b;font-weight:600;">{{REQUEST_TIME}}</td></tr></table>
<p style="margin:0 0 12px;font-family:Helvetica,Arial,sans-serif;font-size:13px;line-height:20px;mso-line-height-rule:exactly;color:#71717a;">If you did not ask to sign in, ignore this email. Nobody can get in without the link or the code above.</p>
</td></tr>
</table>
<table role="presentation" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:100%;">
<tr><td style="padding:20px 32px 0;">
<p style="margin:0 0 8px;font-family:Helvetica,Arial,sans-serif;font-size:12px;line-height:18px;color:#71717a;">{{PRODUCT_NAME}} sends this only when a sign-in is requested.</p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>

How it works

One send, start to finish. Every message is in your logs with its delivery events.

01

Somebody types their address

You rate limit, then mint a token bound to that sign-in attempt.

02

You send this

A link and a code, both resolving to the same token.

03

They open it

The token is consumed and a session starts.

04

You expire everything else

Any older unused link for that address stops working.

Code

The same send three ways. Each one fills the placeholders, reads the two bodies, and posts them to POST /v1/emails.

npm install @emails.sh/sdk
import { readFileSync } from 'node:fs';
import { Emailssh } from '@emails.sh/sdk';

const mail = new Emailssh({ apiKey: process.env.EMAILSSH_API_KEY });

// What changes per send. In your app these come from the row you just
// wrote, not from literals.
const vars: Record<string, string> = {
  PRODUCT_NAME: 'Acme',
  LOGIN_URL: 'https://acme.com/signin?token=2ab9de',
  LOGIN_CODE: '739204',
  EXPIRY_MINUTES: '10',
  DEVICE: 'Chrome on macOS',
  REQUEST_TIME: '31 July 2026 at 09:12 UTC',
};

const fill = (body: string) =>
  Object.entries(vars).reduce(
    (out, [name, value]) => out.replaceAll('{{' + name + '}}', value),
    body
  );

const { id } = await mail.send({
  from: 'Acme <onboarding@emails.sh>',
  to: ['ada@example.com'],
  subject: fill('Your sign-in link for {{PRODUCT_NAME}}'),
  html: fill(readFileSync('email.html', 'utf8')),
  text: fill(readFileSync('email.txt', 'utf8')),
  tags: { type: 'magic-link' }
});

// Queued, not delivered. Ask for the id later, or take the webhook.
console.log(id);

Questions

Why show the device?

Because a magic link email that arrives unprompted is the only warning the reader gets. "Chrome on macOS" is enough for somebody on a Windows laptop to know that the request was not theirs.

Ten minutes is short. Why not an hour?

The link is a live session waiting to happen. Ten minutes is longer than the walk from the form to the inbox, and asking again is one click. If your users are on slow corporate mail, measure the delivery time in the logs and set the expiry from that number rather than from a guess.

Can the same email do sign-up and sign-in?

It can, and doing so avoids telling an attacker whether an address is registered. Keep the wording neutral: this template says sign in without claiming the account already existed.

Send this one in the next five minutes.

Before your domain is verified you can send from onboarding@emails.sh, so the first send needs a key and nothing else. Verification is three DNS records and one screen.

Every message has delivery events you can read: accepted, delivered, bounced, complained. No support ticket to find out whether it arrived.

More authentication templates