# Magic link sign-in

A passwordless sign-in email template with a link, a fallback code, and the device that asked. Table-based HTML, a plain-text alternative, and the send call in TypeScript, Python, and curl.

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.

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.

Topics: magic link, passwordless, sign in, otp.

## 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.

## What you need

- **An emails.sh API key**: Created in the dashboard, starting with esh_. It stays on your server.
- **A sending domain**: Verified with three DNS records, or send from onboarding@emails.sh until yours is ready.
- **A single-use login token**: Short expiry, consumed on first use, tied to the browser that asked where you can.
- **The request context**: Device and browser, read from the user agent on the sign-in request.

## Subject

```text
Your sign-in link for {{PRODUCT_NAME}}
```

## HTML body

```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>
```

## Plain text body

```text
Sign in to {{PRODUCT_NAME}}

Here is the link you asked for. It signs you in once and then stops
working, and it expires in {{EXPIRY_MINUTES}} minutes.

{{LOGIN_URL}}

Or type this code into the tab you started from: {{LOGIN_CODE}}

Requested from {{DEVICE}} on {{REQUEST_TIME}}.

If you did not ask to sign in, ignore this email.
```

## Placeholders

| Placeholder | What it is | Example |
| --- | --- | --- |
| {{PRODUCT_NAME}} | Your product, as the reader knows it. | Acme |
| {{LOGIN_URL}} | The sign-in link. Single use. | https://acme.com/signin?token=2ab9de |
| {{LOGIN_CODE}} | The same token, typeable. | 739204 |
| {{EXPIRY_MINUTES}} | How long the link stays good. | 10 |
| {{DEVICE}} | The device and browser that asked. | Chrome on macOS |
| {{REQUEST_TIME}} | When the request was made, with the zone spelled out. | 31 July 2026 at 09:12 UTC |

## How it works

1. **Somebody types their address** You rate limit, then mint a token bound to that sign-in attempt.
2. **You send this** A link and a code, both resolving to the same token.
3. **They open it** The token is consumed and a session starts.
4. **You expire everything else** Any older unused link for that address stops working.

## Code

### TypeScript

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

### Python

`pip install emailssh`

```
import os

from emailssh import Emailssh

mail = Emailssh(api_key=os.environ["EMAILSSH_API_KEY"])

# What changes per send. In your app these come from the row you just
# wrote, not from literals.
values = {
    "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",
}


def fill(body: str) -> str:
    for name, value in values.items():
        body = body.replace("{{" + name + "}}", value)
    return body


with open("email.html", encoding="utf-8") as f:
    html = f.read()
with open("email.txt", encoding="utf-8") as f:
    text = f.read()

sent = mail.send(
    # from_ rather than from, which is a Python keyword.
    from_="Acme <onboarding@emails.sh>",
    to=["ada@example.com"],
    subject=fill("Your sign-in link for {{PRODUCT_NAME}}"),
    html=fill(html),
    text=fill(text),
    tags={"type": "magic-link"},
)

# Queued, not delivered. Ask for the id later, or take the webhook.
print(sent["id"])
```

### curl

`Anywhere with curl and jq`

```
export EMAILSSH_API_KEY=esh_live_...

# The API substitutes nothing, so fill the placeholders before you post.
fill() {
  sed \
      -e 's|{{PRODUCT_NAME}}|Acme|g' \
      -e 's|{{LOGIN_URL}}|https://acme.com/signin?token=2ab9de|g' \
      -e 's|{{LOGIN_CODE}}|739204|g' \
      -e 's|{{EXPIRY_MINUTES}}|10|g' \
      -e 's|{{DEVICE}}|Chrome on macOS|g' \
      -e 's|{{REQUEST_TIME}}|31 July 2026 at 09:12 UTC|g' "$1"
}

curl -sS https://emails.sh/v1/emails \
  -H "Authorization: Bearer $EMAILSSH_API_KEY" \
  -H "Content-Type: application/json" \
  -d "$(jq -n \
      --arg subject 'Your sign-in link for Acme' \
      --arg html "$(fill email.html)" \
      --arg text "$(fill email.txt)" \
      '{from: "Acme <onboarding@emails.sh>",
        to: ["ada@example.com"],
        subject: $subject,
        html: $html,
        text: $text,
        tags: {type: "magic-link"}}')"
```

## 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.

Docs: https://emails.sh/docs.md
All templates: https://emails.sh/templates.md