Passwordless Sign-In

Last updated on October 10, 2026.

Sign people in with an emailed link or a 6-digit code, with limits, safe confirmation and tests.

Signing in with an emailed code instead of a password

Passwordless sign-in lets people sign in with their email address and no password. Insites gives you the parts: users, the sign_in tag, tables, email notifications and Liquid. This page shows how to put them together, step by step, so you can build it with an AI assistant.

There are two ways to do it:

  • An emailed link. The person clicks a link in an email. This is the main way, and it is used in production on two Insites products: Nucleus and the Insites Migration Tool.
  • An emailed 6-digit code. The person types a code from the email. Use it when people sign in on one device and read email on another.

Both versions were tested on a production Insites instance: the emailed link and the code. All cases pass, including sign-ins that happen at the same moment.

Both ways share the same parts: the list of people who may sign in, the visitor address, a fair limit on attempts, a protected form, and a registered email. Build those first, then build one of the two ways.

What You Need Before You Start

  • An Insites instance you can deploy to, through Insites CloudShell or the Insites GitHub App.
  • An AI assistant connected to your instance. See Getting Set Up with AI.
  • An email inbox you can read, to receive the test emails.
  • On a staging instance, the test email recipients set in your instance settings. See Step 5.

Note: GraphQL only accepts text values written inside double quotation marks. So that every example on this page can be copied as it is, each table name, field name and status is passed into the query as a variable instead. The Insites Logic Engine recommends passing names this way.

How It Works

  1. The person enters their email address on a sign-in page.
  2. A request page checks the limits and the address, saves one row, and sends an email only if the address may sign in. It gives the same answer for every address.
  3. The email holds a link, or a code, that works once for a few minutes.
  4. The person opens the link and presses a button, or types the code. A second request checks everything again, uses up the link or code, and signs the person in with the sign_in tag.

Step 1: Make Your Users the Allow-List

A person may sign in only if a user with their email address exists and has a member role. Check the role, not only the user. On an instance, every CRM contact is a user record, so a user alone does not mean the person was invited. See Email Allow-List for the full reasoning, and for a table-based list instead.

First, add roles to your users as an array property. Without this file, the role check below fails with the GraphQL error array_contains only works for arrays. The partial then returns blank for every address, so nobody can sign in, and no Liquid error tells you why. If your instance already has app/user.yml, add the property to it.

app/user.yml

properties:
  - name: roles
    type: array

app/views/partials/sign_in/allowed_user.liquid

{%- comment -%}
  The allow-list. Returns the id of the user with this email who has the member role,
  or blank when there is none. Called with:
    function user_id = 'sign_in/allowed_user', email: email
{%- endcomment -%}
{%- graphql found, em: email, f_roles: 'roles', role: 'member' -%}
query ($em: String!, $f_roles: String!, $role: String!) {
  users(per_page: 1, filter: { email: { value: $em }, properties: [{ name: $f_roles, array_contains: [$role] }] }) {
    results { id }
  }
}
{%- endgraphql -%}
{%- assign user_id = found.users.results.first.id | default: '' -%}
{%- return user_id -%}

To invite a person, create their user with a long random password that nobody is shown, then give them the member role. Sign-in never uses the password. A migration is a good place to add the first people:

app/migrations/20261009090000_invite_first_member.liquid

{%- assign pw = 48 | random_string -%}
{%- assign member = 'member' | split: ',' -%}
{%- graphql made, em: 'person@example.com', pw: pw -%}
mutation ($em: String!, $pw: String!) {
  user_create(user: { email: $em, password: $pw }) { id }
}
{%- endgraphql -%}
{%- graphql given, id: made.user_create.id, f_roles: 'roles', roles: member -%}
mutation ($id: ID!, $f_roles: String!, $roles: [String]) {
  user_update(id: $id, user: { properties: [{ name: $f_roles, value_array: $roles }] }) { id }
}
{%- endgraphql -%}

To give the role to a user who already exists, run the second mutation alone with their id. Build the list of roles with split, as shown. In testing, a list written inline as a tag argument, such as roles: ['member'], made the deploy fail. See Admin Role Instead of a Shared Password for more about roles.

Note: Do not let a public page create users. Anyone who can create their own user could put themselves on the list.

Step 2: Read the Visitor Address

Limits are counted per email address and per network address. Read the network address from X-Real-IP. It is the same value as the first entry of X-Forwarded-For. The network edge in front of your instance replaces both headers with the address it saw, so a visitor cannot choose either value.

  • Do not use the last entry of X-Forwarded-For. It is an internal address that changes from request to request. A limit on it counts per server, not per visitor.
  • Do not trust True-Client-IP or Forwarded. They pass through whatever the visitor sends.

app/views/partials/client_ip.liquid

{%- comment -%}
  The visitor network address, for rate limits. Called with:
    function ip = 'client_ip'
  X-Real-IP and the FIRST X-Forwarded-For entry hold the address the edge saw, and the
  edge replaces whatever the client sent in either header. The LAST X-Forwarded-For
  entry is an internal address, so never limit on it.
{%- endcomment -%}
{%- liquid
  assign ip = context.headers.HTTP_X_REAL_IP
  if ip == blank
    assign xff = context.headers.HTTP_X_FORWARDED_FOR | default: 'unknown'
    assign ip = xff | split: ',' | first | strip
  endif
  assign ip = ip | truncate: 60, ''
  return ip
-%}

See Rate Limiting by IP to block an address after repeated failures.

Step 3: Count Attempts So They Cannot Race

A limit such as five wrong guesses must hold even when many guesses arrive at the same moment. The obvious way, reading a count and writing it back plus one, does not hold:

How the count was keptTestResult
Read the count, write the count plus one16 wrong guesses at once, limit 514 guesses were checked
Read the count, write the count plus one8 right codes at once2 sign-ins from one code
Add one with increment16 right codes at once2 sign-ins from one code
One row per slot, with a unique external_id16 wrong guesses at once, limit 55 checked, 11 refused
One row per slot, with a unique external_id16, 8 and 4 right codes at onceExactly 1 sign-in each time

The fix is to give each request a fixed number of slots, and to claim a slot by creating a row. external_id is unique within a table, so when several requests try to create the same slot, exactly one succeeds and the others are refused. A request that wins no slot is turned away.

app/views/partials/sign_in/take_slot.liquid

{%- comment -%}
  Claim one of a fixed number of slots. Only one request can win each slot. Called with:
    function won = 'sign_in/take_slot', table: 'sign_in_code', key: row_id, label: 'try', count: 5
  Returns the slot this request won (1 to count), or 0 when every slot is taken.
{%- endcomment -%}
{%- assign won = 0 -%}
{%- for n in (1..count) -%}
  {%- if won == 0 -%}
    {%- assign ext = key | append: '-' | append: label | append: '-' | append: n -%}
    {%- graphql slot, t: table, f_status: 'status', st: 'slot', ext: ext -%}
    mutation ($t: String!, $f_status: String!, $st: String!, $ext: String) {
      record_create(record: { table: $t, external_id: $ext, properties: [{ name: $f_status, value: $st }] }) { id }
    }
    {%- endgraphql -%}
    {%- if slot.record_create.id != blank -%}
      {%- assign won = n -%}
    {%- endif -%}
  {%- endif -%}
{%- endfor -%}
{%- return won -%}

Use five try slots for guesses, and one use slot for the moment a link or code is used. The one use slot is what makes a link or code work only once.

Step 4: Protect Every Form That Posts

Every form that posts must include the authenticity token:

<input type='hidden' name='authenticity_token' value='{{ context.authenticity_token }}'>

A post without a valid token is not refused. It arrives with an empty session. So set a flag in the session on the page that shows the form, and refuse any post where the flag is missing:

{%- comment -%} on the page that shows the form {%- endcomment -%}
{%- session sign_in_form = 'on' -%}

{%- comment -%} on the page the form posts to {%- endcomment -%}
{%- if context.session.sign_in_form != 'on' -%}
  {%- comment -%} refuse: answer blocked, and write nothing {%- endcomment -%}
{%- endif -%}

Step 5: Send the Email

Send each email through a registered email notification: a file in app/emails/, sent by its name with email_send. Build the values the email needs as one Liquid variable, and pass that one variable as data. In the email, read them as data.to, data.link and so on.

Note: Two ways of sending look as if they work and send nothing. An email_send with the whole email written inline, and no registered template, answers is_scheduled_to_send: true and sends nothing. So does a data value written as an object inside the GraphQL query with variables in it. Use a registered template and one Liquid variable.

{%- liquid
  assign mail = {}
  assign mail.to = email
  assign mail.link = link
-%}
{%- graphql sent, tpl: 'sign_in_link', d: mail -%}
mutation ($tpl: String!, $d: HashObject) {
  email_send(template: { name: $tpl }, data: $d) { is_scheduled_to_send }
}
{%- endgraphql -%}

Note: A staging instance does not send email to real people. Each email goes only to the test email recipients set in your instance settings, with the intended recipient shown in the subject line. Until you set test recipients, you receive nothing. See Staging vs. Production.

Option A: Sign In by Emailed Link

The link carries three values: the email address, a short-lived token for that user from Insites, and a random value called a nonce. Only the nonce is stored, in a table. The Insites token is never written anywhere, and it expires on its own.

Step A1: Create the Table

app/schema/sign_in_link.yml

name: sign_in_link
properties:
  - name: email
    type: string
  - name: nonce
    type: string
  - name: expires_at
    type: datetime
  - name: status
    type: string
  - name: ip
    type: string

A row is pending (a live link), consumed (used to sign in), or miss (the address may not sign in, so nothing was sent). The slot rows from Step 3 go in this table too.

Step A2: Set Your Site Address

The email needs the full address of your site. Add a constant named SITE_ORIGIN, such as https://www.example.com, with no slash at the end.

Step A3: Create the Email

app/emails/sign_in_link.liquid

---
to: '{{ data.to }}'
from: My Site <no-reply@insites.site>
subject: Your sign-in link
---
<p>Use this link to sign in. It works once, for 15 minutes.</p>
<p><a href='{{ data.link | escape }}'>Sign in</a></p>
<p>If you did not ask for it, ignore this email. Nobody can use the link without your inbox.</p>

Step A4: Mint and Send the Link

This partial saves one row for every request. It creates and emails a link only when the address may sign in. temporary_token(expires_in:) takes hours, so 0.25 is 15 minutes.

app/views/partials/sign_in/send_link.liquid

{%- comment -%}
  Called with:
    function status = 'sign_in/send_link', email: email, user_id: user_id, ip: ip
  Saves one row either way. Sends a link only when user_id is not blank.
{%- endcomment -%}
{%- liquid
  assign now_unix = 'now' | date: '%s' | plus: 0
  assign exp_iso = now_unix | plus: 900 | date: '%Y-%m-%dT%H:%M:%SZ'
  assign nonce = 40 | random_string
  assign row_status = 'miss'
  if user_id != blank
    assign row_status = 'pending'
  endif
-%}
{%- graphql row, t: 'sign_in_link', f_email: 'email', f_nonce: 'nonce', f_exp: 'expires_at', f_status: 'status', f_ip: 'ip', em: email, n: nonce, exp: exp_iso, st: row_status, ip: ip -%}
mutation ($t: String!, $f_email: String!, $f_nonce: String!, $f_exp: String!, $f_status: String!, $f_ip: String!, $em: String!, $n: String!, $exp: String!, $st: String!, $ip: String!) {
  record_create(record: { table: $t, properties: [
    { name: $f_email, value: $em }
    { name: $f_nonce, value: $n }
    { name: $f_exp, value: $exp }
    { name: $f_status, value: $st }
    { name: $f_ip, value: $ip }
  ] }) { id }
}
{%- endgraphql -%}
{%- if user_id != blank -%}
  {%- graphql found, em: email, h: 0.25 -%}
  query ($em: String, $h: Float) {
    u: user(email: $em, is_deleted: false) { id temporary_token(expires_in: $h) }
  }
  {%- endgraphql -%}
  {%- liquid
    assign enc_e = email | url_encode
    assign enc_t = found.u.temporary_token | url_encode
    assign enc_n = nonce | url_encode
    assign link = context.constants.SITE_ORIGIN | append: '/sign-in/link?e=' | append: enc_e | append: '&t=' | append: enc_t | append: '&n=' | append: enc_n
    assign mail = {}
    assign mail.to = email
    assign mail.link = link
  -%}
  {%- graphql sent, tpl: 'sign_in_link', d: mail -%}
  mutation ($tpl: String!, $d: HashObject) {
    email_send(template: { name: $tpl }, data: $d) { is_scheduled_to_send }
  }
  {%- endgraphql -%}
{%- endif -%}
{%- return row_status -%}

Step A5: The Sign-In Page and the Request Page

The sign-in page shows the form and sets the session flag from Step 4.

app/views/pages/sign-in.liquid

---
slug: sign-in
---
{%- session sign_in_form = 'on' -%}
{%- if context.params.sent == '1' -%}
  <p>If that address can sign in, a link is on its way. It works once, for 15 minutes.</p>
{%- elsif context.params.stale == '1' -%}
  <p>That link has been used or has expired. Enter your email address for a new one.</p>
{%- endif -%}
<form method='post' action='/sign-in/send'>
  <input type='hidden' name='authenticity_token' value='{{ context.authenticity_token }}'>
  <label for='email'>Email address</label>
  <input id='email' name='email' type='email' autocomplete='email' required>
  <button type='submit'>Email me a sign-in link</button>
</form>

The request page gives the same answer for every address: it always redirects to /sign-in?sent=1. It allows 5 requests per email address and 20 per network address in 15 minutes. Every row counts, whether or not the address may sign in, so the limits reveal nothing either.

app/views/pages/sign-in/send.liquid

---
slug: sign-in/send
method: post
layout: ''
---
{%- liquid
  unless context.session.sign_in_form == 'on'
    redirect_to '/sign-in'
  endunless
  assign email = context.params.email | default: '' | strip | downcase
  function ip = 'client_ip'
  assign now_unix = 'now' | date: '%s' | plus: 0
  assign since_iso = now_unix | minus: 900 | date: '%Y-%m-%dT%H:%M:%SZ'
-%}
{%- graphql look, t: 'sign_in_link', f_email: 'email', f_ip: 'ip', em: email, ip: ip, since: since_iso -%}
query ($t: String, $f_email: String!, $f_ip: String!, $em: String, $ip: String, $since: String) {
  for_email: records(per_page: 1, filter: { table: { value: $t }, created_at: { gte: $since }, properties: [{ name: $f_email, value: $em }] }) { total_entries }
  for_ip: records(per_page: 1, filter: { table: { value: $t }, created_at: { gte: $since }, properties: [{ name: $f_ip, value: $ip }] }) { total_entries }
}
{%- endgraphql -%}
{%- liquid
  if email contains '@' and look.for_email.total_entries < 5 and look.for_ip.total_entries < 20
    function user_id = 'sign_in/allowed_user', email: email
    function row_status = 'sign_in/send_link', email: email, user_id: user_id, ip: ip
  endif
  redirect_to '/sign-in?sent=1'
-%}

Step A6: Check a Link Without Using It

All four must hold: the nonce belongs to a pending row for this very address, the row has not expired, and Insites confirms the token belongs to that user and is still live. A changed, swapped, expired or used link fails at least one.

app/views/partials/sign_in/check_link.liquid

{%- comment -%}
  Called with:
    function r = 'sign_in/check_link', email: e, token: t, nonce: n
  Returns ok (true or false), request_id and user_id.
{%- endcomment -%}
{%- assign now_unix = 'now' | date: '%s' | plus: 0 -%}
{%- graphql chk, t: 'sign_in_link', f_nonce: 'nonce', f_status: 'status', live: 'pending', em: email, non: nonce, tok: token -%}
query ($t: String, $f_nonce: String!, $f_status: String!, $live: String, $em: String, $non: String, $tok: String!) {
  req: records(per_page: 1, filter: { table: { value: $t }, properties: [{ name: $f_nonce, value: $non }, { name: $f_status, value: $live }] }) {
    results { id properties }
  }
  u: user(email: $em, is_deleted: false) { id authenticate { temporary_token(token: $tok) } }
}
{%- endgraphql -%}
{%- liquid
  assign r = chk.req.results.first
  assign ok = true
  if r == blank or email == blank or token == blank or nonce == blank
    assign ok = false
  else
    assign r_exp = r.properties.expires_at | date: '%s' | plus: 0
    if r_exp <= now_unix or r.properties.email != email
      assign ok = false
    endif
  endif
  if chk.u.authenticate.temporary_token != true
    assign ok = false
  endif
  assign out = {}
  assign out.ok = ok
  assign out.request_id = r.id
  assign out.user_id = chk.u.id
  return out
-%}

Step A7: Never Sign In When the Link Is Opened

Email security scanners and link previews open every link before the person does. If opening the link signed the person in, the scanner would use the link up first. So the page the link opens changes nothing. It checks the link and shows a button. The button posts to a second page, which signs the person in.

app/views/pages/sign-in/link.liquid

---
slug: sign-in/link
---
{%- session sign_in_form = 'on' -%}
{%- liquid
  assign em = context.params.e | default: '' | strip | downcase
  assign tok = context.params.t | default: ''
  assign non = context.params.n | default: ''
  function r = 'sign_in/check_link', email: em, token: tok, nonce: non
  unless r.ok
    redirect_to '/sign-in?stale=1'
  endunless
-%}
<p>You are signing in as <strong>{{ em | escape }}</strong>.</p>
<form method='post' action='/sign-in/confirm'>
  <input type='hidden' name='authenticity_token' value='{{ context.authenticity_token }}'>
  <input type='hidden' name='e' value='{{ em | escape }}'>
  <input type='hidden' name='t' value='{{ tok | escape }}'>
  <input type='hidden' name='n' value='{{ non | escape }}'>
  <button type='submit'>Continue</button>
</form>

The confirm page checks everything again, claims the single use slot from Step 3, marks the row consumed, and only then signs the person in. The button page is a convenience, not a lock, so the check is repeated here.

app/views/pages/sign-in/confirm.liquid

---
slug: sign-in/confirm
method: post
layout: ''
---
{%- liquid
  unless context.session.sign_in_form == 'on'
    redirect_to '/sign-in?stale=1'
  endunless
  assign em = context.params.e | default: '' | strip | downcase
  assign tok = context.params.t | default: ''
  assign non = context.params.n | default: ''
  function r = 'sign_in/check_link', email: em, token: tok, nonce: non
  unless r.ok
    redirect_to '/sign-in?stale=1'
  endunless
  function used = 'sign_in/take_slot', table: 'sign_in_link', key: r.request_id, label: 'use', count: 1
  unless used == 1
    redirect_to '/sign-in?stale=1'
  endunless
-%}
{%- graphql burn, t: 'sign_in_link', f_status: 'status', st: 'consumed', id: r.request_id -%}
mutation ($t: String!, $f_status: String!, $st: String!, $id: ID!) {
  records_update_all(table: $t, sync: true, filter: { id: { value: $id } }, record: { properties: [{ name: $f_status, value: $st }] }) { count }
}
{%- endgraphql -%}
{%- sign_in user_id: r.user_id, timeout_in_minutes: 720 -%}
{%- redirect_to '/' -%}

Option B: Sign In by Emailed 6-Digit Code

This version answers as JSON, at /api/auth/request-code and /api/auth/verify-code. A JSON endpoint is a page with format: json, and its slug does not end in .json. Each answer carries one of these codes: code_sent, invalid, expired, too_many, blocked or ok.

Step B1: Create the Table

Never store the code itself. Store a keyed hash of it: an HMAC of the email address and the code, made with a secret key that only your instance holds. Because the email is part of the hash, a code sent to one address can never match another address.

app/schema/sign_in_code.yml

name: sign_in_code
properties:
  - name: email
    type: string
  - name: code_hash
    type: string
  - name: expires_at
    type: datetime
  - name: status
    type: string
  - name: ip
    type: string

A row is pending, consumed, killed (too many wrong guesses) or miss (the address may not sign in, so nothing was sent).

Step B2: Make the Secret Key

Make the key in a migration, so no person ever sees it. It is saved as the constant SIGN_IN_HMAC_KEY.

app/migrations/20261009090100_sign_in_hmac_key.liquid

{%- if context.constants.SIGN_IN_HMAC_KEY == blank -%}
  {%- assign key = 64 | random_string -%}
  {%- graphql set_key, n: 'SIGN_IN_HMAC_KEY', v: key -%}
  mutation ($n: String!, $v: String!) { constant_set(name: $n, value: $v) { name } }
  {%- endgraphql -%}
{%- endif -%}

app/views/partials/sign_in/code_hash.liquid

{%- comment -%}
  The stored form of a code. Called with:
    function h = 'sign_in/code_hash', email: email, code: code
  Returns blank when the key is not set, and every caller treats that as a refusal.
{%- endcomment -%}
{%- liquid
  assign key = context.constants.SIGN_IN_HMAC_KEY
  assign h = ''
  if key != blank
    assign msg = email | append: ':' | append: code
    assign h = msg | compute_hmac: key
  endif
  return h
-%}

Step B3: Create the Email

app/emails/sign_in_code.liquid

---
to: '{{ data.to }}'
from: My Site <no-reply@insites.site>
subject: 'Your sign-in code: {{ data.code }}'
---
<p>Your sign-in code is:</p>
<p><strong>{{ data.code | escape }}</strong></p>
<p>It works once, for {{ data.minutes | escape }} minutes. If you did not ask for it, ignore this email.</p>

Step B4: The Request Endpoint

It answers code_sent for every address, allowed or not, and it does the same work either way: one user lookup, one hash and one row. It allows 5 requests per email address and 20 per network address in 15 minutes. A code lasts 10 minutes.

app/views/pages/api/auth/request-code.liquid

---
slug: api/auth/request-code
method: post
format: json
layout: ''
---
{%- comment -%}
  public endpoint: anyone may ask for a sign-in code. The answer is the same for every address.
{%- endcomment -%}
{%- liquid
  assign status = 200
  assign result = 'code_sent'
  if context.session.sign_in_form != 'on'
    assign status = 403
    assign result = 'blocked'
  endif
  assign email = context.params.email | default: '' | strip | downcase
  if result == 'code_sent'
    unless email contains '@'
      assign status = 422
      assign result = 'invalid'
    endunless
    if email contains ' ' or email.size < 6 or email.size > 254
      assign status = 422
      assign result = 'invalid'
    endif
  endif
-%}
{%- if result == 'code_sent' -%}
  {%- liquid
    function ip = 'client_ip'
    assign now_unix = 'now' | date: '%s' | plus: 0
    assign since_iso = now_unix | minus: 900 | date: '%Y-%m-%dT%H:%M:%SZ'
  -%}
  {%- graphql look, t: 'sign_in_code', f_email: 'email', f_ip: 'ip', em: email, ip: ip, since: since_iso -%}
  query ($t: String, $f_email: String!, $f_ip: String!, $em: String, $ip: String, $since: String) {
    for_email: records(per_page: 1, filter: { table: { value: $t }, created_at: { gte: $since }, properties: [{ name: $f_email, value: $em }] }) { total_entries }
    for_ip: records(per_page: 1, filter: { table: { value: $t }, created_at: { gte: $since }, properties: [{ name: $f_ip, value: $ip }] }) { total_entries }
  }
  {%- endgraphql -%}
  {%- if look.for_email.total_entries >= 5 or look.for_ip.total_entries >= 20 -%}
    {%- assign status = 429 -%}
    {%- assign result = 'too_many' -%}
  {%- endif -%}
{%- endif -%}
{%- if result == 'code_sent' -%}
  {%- liquid
    comment
      Six random digits. random_string is letters and digits, so keep only the digits.
    endcomment
    assign code = ''
    for i in (1..10)
      assign digits = 32 | random_string | replace_regex: '[^0-9]', ''
      assign code = code | append: digits
      if code.size >= 6
        break
      endif
    endfor
    assign code = code | slice: 0, 6
    function code_hash = 'sign_in/code_hash', email: email, code: code
    function user_id = 'sign_in/allowed_user', email: email
    assign row_status = 'miss'
    if user_id != blank
      assign row_status = 'pending'
    endif
    assign expires_iso = now_unix | plus: 600 | date: '%Y-%m-%dT%H:%M:%SZ'
  -%}
  {%- if code_hash == blank -%}
    {%- assign status = 503 -%}
    {%- assign result = 'blocked' -%}
  {%- else -%}
    {%- graphql row, t: 'sign_in_code', f_email: 'email', f_hash: 'code_hash', f_exp: 'expires_at', f_status: 'status', f_ip: 'ip', em: email, h: code_hash, exp: expires_iso, st: row_status, ip: ip -%}
    mutation ($t: String!, $f_email: String!, $f_hash: String!, $f_exp: String!, $f_status: String!, $f_ip: String!, $em: String!, $h: String!, $exp: String!, $st: String!, $ip: String!) {
      record_create(record: { table: $t, properties: [
        { name: $f_email, value: $em }
        { name: $f_hash, value: $h }
        { name: $f_exp, value: $exp }
        { name: $f_status, value: $st }
        { name: $f_ip, value: $ip }
      ] }) { id }
    }
    {%- endgraphql -%}
    {%- if user_id != blank -%}
      {%- liquid
        assign mail = {}
        assign mail.to = email
        assign mail.code = code
        assign mail.minutes = 10
      -%}
      {%- graphql sent, tpl: 'sign_in_code', d: mail -%}
      mutation ($tpl: String!, $d: HashObject) {
        email_send(template: { name: $tpl }, data: $d) { is_scheduled_to_send }
      }
      {%- endgraphql -%}
    {%- endif -%}
  {%- endif -%}
{%- endif -%}
{%- response_status status -%}
{%- assign out = {} -%}
{%- assign out.code = result -%}
{{ out | json }}

Step B5: The Verify Endpoint

Only the newest request for an address counts, and it must be pending and not expired. Each guess first claims one of five try slots. A guess with no slot left, or a wrong guess in the last slot, kills the request. A right code then claims the single use slot, marks the row consumed, and signs the person in. A used, expired or killed code never works again.

app/views/pages/api/auth/verify-code.liquid

---
slug: api/auth/verify-code
method: post
format: json
layout: ''
---
{%- comment -%}
  public endpoint: checks a sign-in code and, if it is right, signs the person in.
{%- endcomment -%}
{%- liquid
  assign status = 401
  assign result = 'invalid'
  assign max_attempts = 5
  assign checked = false
  assign guess = 0
  if context.session.sign_in_form != 'on'
    assign status = 403
    assign result = 'blocked'
  endif
  assign email = context.params.email | default: '' | strip | downcase
  assign code = context.params.code | default: '' | remove: ' ' | strip
-%}
{%- if result == 'invalid' and email != blank and code != blank -%}
  {%- graphql look, t: 'sign_in_code', f_email: 'email', em: email -%}
  query ($t: String, $f_email: String!, $em: String) {
    req: records(per_page: 1, sort: [{ created_at: { order: DESC } }], filter: { table: { value: $t }, properties: [{ name: $f_email, value: $em }] }) {
      results { id properties }
    }
  }
  {%- endgraphql -%}
  {%- liquid
    assign r = look.req.results.first
    assign now_unix = 'now' | date: '%s' | plus: 0
    if r == blank or r.properties.status == 'miss' or r.properties.status == 'consumed'
      assign result = 'invalid'
    elsif r.properties.status == 'killed'
      assign status = 429
      assign result = 'too_many'
    else
      assign exp = r.properties.expires_at | date: '%s' | plus: 0
      if exp <= now_unix
        assign status = 410
        assign result = 'expired'
      else
        assign checked = true
      endif
    endif
  -%}
{%- endif -%}
{%- if checked -%}
  {%- function guess = 'sign_in/take_slot', table: 'sign_in_code', key: r.id, label: 'try', count: max_attempts -%}
  {%- if guess == 0 -%}
    {%- assign checked = false -%}
    {%- assign status = 429 -%}
    {%- assign result = 'too_many' -%}
  {%- endif -%}
{%- endif -%}
{%- if checked -%}
  {%- function h = 'sign_in/code_hash', email: email, code: code -%}
  {%- function user_id = 'sign_in/allowed_user', email: email -%}
  {%- if h != blank and h == r.properties.code_hash and user_id != blank -%}
    {%- function use = 'sign_in/take_slot', table: 'sign_in_code', key: r.id, label: 'use', count: 1 -%}
    {%- if use == 1 -%}
      {%- graphql used, t: 'sign_in_code', f_status: 'status', st: 'consumed', id: r.id -%}
      mutation ($t: String!, $f_status: String!, $st: String!, $id: ID!) {
        records_update_all(table: $t, sync: true, filter: { id: { value: $id } }, record: { properties: [{ name: $f_status, value: $st }] }) { count }
      }
      {%- endgraphql -%}
      {%- sign_in user_id: user_id, timeout_in_minutes: 720 -%}
      {%- assign status = 200 -%}
      {%- assign result = 'ok' -%}
    {%- endif -%}
  {%- endif -%}
{%- endif -%}
{%- assign kill = false -%}
{%- if result == 'too_many' and r != blank -%}
  {%- assign kill = true -%}
{%- elsif result != 'ok' and guess == max_attempts -%}
  {%- assign kill = true -%}
{%- endif -%}
{%- if kill -%}
  {%- graphql killed, t: 'sign_in_code', f_status: 'status', live: 'pending', st: 'killed', id: r.id -%}
  mutation ($t: String!, $f_status: String!, $live: String!, $st: String!, $id: ID!) {
    records_update_all(table: $t, sync: true, filter: { id: { value: $id }, properties: [{ name: $f_status, value: $live }] }, record: { properties: [{ name: $f_status, value: $st }] }) { count }
  }
  {%- endgraphql -%}
  {%- assign status = 429 -%}
  {%- assign result = 'too_many' -%}
{%- endif -%}
{%- response_status status -%}
{%- assign out = {} -%}
{%- assign out.code = result -%}
{{ out | json }}

Step B6: The Page With the Two Forms

This page sets the session flag from Step 4, and posts both forms to the endpoints.

app/views/pages/sign-in-code.liquid

---
slug: sign-in-code
---
{%- session sign_in_form = 'on' -%}
<form method='post' action='/api/auth/request-code'>
  <input type='hidden' name='authenticity_token' value='{{ context.authenticity_token }}'>
  <label for='ask-email'>Email address</label>
  <input id='ask-email' name='email' type='email' autocomplete='email' required>
  <button type='submit'>Email me a code</button>
  <output></output>
</form>
<form method='post' action='/api/auth/verify-code'>
  <input type='hidden' name='authenticity_token' value='{{ context.authenticity_token }}'>
  <label for='check-email'>Email address</label>
  <input id='check-email' name='email' type='email' autocomplete='email' required>
  <label for='check-code'>Six-digit code</label>
  <input id='check-code' name='code' inputmode='numeric' autocomplete='one-time-code' pattern='[0-9]{6}' required>
  <button type='submit'>Sign in</button>
  <output></output>
</form>
<script>
  var messages = {
    code_sent: 'If that address can sign in, a code is on its way. It works once, for 10 minutes.',
    ok: 'Signed in.',
    invalid: 'That code is not right.',
    expired: 'That code has expired. Ask for a new one.',
    too_many: 'Too many tries. Wait a while, then ask for a new code.',
    blocked: 'Reload this page and try again.'
  };
  document.querySelectorAll('form').forEach(function (form) {
    form.addEventListener('submit', function (event) {
      event.preventDefault();
      var out = form.querySelector('output');
      fetch(form.action, { method: 'POST', body: new URLSearchParams(new FormData(form)), headers: { Accept: 'application/json' } })
        .then(function (response) { return response.json(); })
        .then(function (answer) {
          out.textContent = messages[answer.code] || answer.code;
          if (answer.code === 'ok') { window.location = '/'; }
        });
    });
  });
</script>

Limits at a Glance

LimitValue on this page
Link lifetime15 minutes
Code lifetime10 minutes
Requests per email address5 in 15 minutes
Requests per network address20 in 15 minutes
Wrong guesses per code5, then the code is killed
Uses per link or code1
Sign-in length720 minutes (12 hours)

Test It

Run every case. For the link, read each code case as the matching link case.

  1. The right code signs in. The answer is ok, and a page that shows context.current_user shows the person. (Link: Continue signs in.)
  2. A wrong code fails. The answer is invalid, and nobody is signed in.
  3. Five wrong codes, then the right one, fails. The fifth wrong code answers too_many. The right code then also answers too_many, and the row is killed.
  4. An expired code fails. Shorten the lifetime to 1 minute, wait over a minute, and try the right code. The answer is expired. Then put the lifetime back.
  5. A used code fails. Use the same code again from a fresh browser session. The answer is invalid. (Link: open a used link, and you are sent back to the sign-in page.)
  6. A code for one address fails with another. The answer is invalid.
  7. An unknown address gets the same answer. The answer is code_sent, a miss row is saved, and no email is sent.
  8. The request limits hold. The sixth request for one address in 15 minutes answers too_many, for a known and an unknown address alike. Requests from one network address are refused once 20 rows from it exist in the window.
  9. A post without a valid token fails. With no token, no session cookie, or a made-up token, both endpoints answer blocked, and nothing is written.

Then test the race: send 16 wrong guesses at the same moment, and check that no more than 5 are checked. Send the right code 16 times at the same moment, and check that exactly one signs in. For the link, open it without pressing Continue, then open it again: the button must still be there.

Things to Know

  • Only the newest request counts. When a person asks again, an older code answers invalid.
  • The answer time is close, not equal. In the tests, a known address answered 50 to 80 milliseconds slower than an unknown one, because its email is queued. The difference is small.
  • Anyone who knows an address can use up its five guesses by guessing wrong. The person then asks for a new code. Any guess limit has this cost.
  • Do not cache these pages. Ask your AI to send a Cache-Control: no-store header on the sign-in, link and endpoint pages.
  • A write can fail without stopping the page. For example, a number passed as a field value fails the whole write, and the result only has an errors key. Pass every field value as text, and check that each write returned an id or a count.

A Prompt for Your AI Assistant

Copy this into your AI assistant. Choose the link or the code first.

Build passwordless sign-in on my Insites instance by following
docs.insites.io/developers-guide/passwordless-sign-in. Use the emailed link
(or: the 6-digit code). Read the Insites Logic Engine first, and never guess.

- Allowed people are users with the member role. Never let a public page create users.
- Read the visitor address with the client_ip partial on that page.
- Count guesses and uses with the take_slot partial, never with a counter you read
  and write back.
- Never sign anyone in on a GET. Opening a link only shows a button.
- Store only a keyed hash of a code, never the code.
- Check the session flag on every POST.
- Send email through a registered email notification in app/emails, with one Liquid
  variable as data. If this is a staging instance, remind me to set test email recipients.

When it is built, run every case in the Test It list and show me the result of each.

Next Steps

Have a suggestion for this page?

Didn't quite find what you are looking for or have feedback on how we can make the content better then we would love to hear from you. Please provide us feedback and we will get back to you shortly.