AI answers drawn only from these docs, with links to the pages used.

Password Reset

Last updated on October 13, 2026.

Let people choose a new password from an emailed link, using the token Insites makes, with limits, a protected form and tests.

Password reset lets a person who forgot their password choose a new one from a link in an email. Insites gives you the parts: a short-lived token on every user, a way to check it, 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.

The token comes from Insites itself, so nothing secret is stored: Insites makes the token, checks it, and stops accepting it once the password changes. Every file on this page was tested as written on a production Insites instance on 13 October 2026, and all eight test cases passed.

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 and field name 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 reset page.
  2. A request page checks the limits, saves one row, and sends an email only if an account has that address. It gives the same answer for every address.
  3. The email holds a link with the address and a token that Insites made for that account. The token lasts one hour.
  4. The person opens the link. The page checks the token and shows a form for the new password. Opening the link changes nothing.
  5. The person saves a new password. A second request checks the token again, saves the password, and signs the person in. Saving the password ends the token.

How Long a Link Works

Insites makes the token with temporary_token(expires_in:), where expires_in is in hours, and checks it with authenticate { temporary_token(token:) }. In the tests:

  • A link works until the password changes or the hour runs out. Opening it does not use it up. Two links sent to one person both work, and saving a new password with either one ends both.
  • A token works only for its own account. Another person's live token is refused.
  • A changed token is refused. The token shows the user id and the expiry time in plain base64, then a signature. Changing any part of it makes it fail.
  • A token past its expiry is refused. A token made for 0.01 hours (36 seconds) worked at 26 seconds and was refused at 46.

Because a link keeps working until it is used to change the password, keep its life short. This page uses one hour, and the email says so.

Step 1: Read the Visitor Address

The limits count requests per network address. Read the address from X-Real-IP, or the first entry of X-Forwarded-For. The last entry is an internal address, so never limit on it. This is the same partial as in Passwordless Sign-In.

app/views/partials/password_reset/client_ip.liquid

{%- comment -%}
  The visitor network address, for rate limits. Called with:
    function ip = 'password_reset/client_ip'
  X-Real-IP and the FIRST X-Forwarded-For entry hold the address the edge saw. 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
-%}

Step 2: Create the Table for the Limits

The table holds one row per request. It is only used to count requests: the token itself is never stored.

app/schema/password_reset.yml

name: password_reset
properties:
  - name: email
    type: string
  - name: ip
    type: string
  - name: status
    type: string

A row is sent (an email went out) or miss (no account has that address, so nothing was sent).

Step 3: 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. Do not build the link from the address of the request: anyone who can change that header could then send a reset email that points at their own site.

Step 4: Check a Token

This partial returns the id of the account with that email when the token is live for that account, and blank otherwise. Both the link page and the save page use it.

app/views/partials/password_reset/check_token.liquid

{%- comment -%}
  Called with:
    function user_id = 'password_reset/check_token', email: email, token: token
  Returns the id of the account with that email when the token is live for that account,
  or blank. The platform refuses a token once it has expired or the password has changed.
{%- endcomment -%}
{%- assign user_id = '' -%}
{%- if email != blank and token != blank -%}
  {%- graphql chk, em: email, tok: token -%}
  query ($em: String, $tok: String!) {
    u: user(email: $em, is_deleted: false) { id authenticate { temporary_token(token: $tok) } }
  }
  {%- endgraphql -%}
  {%- if chk.u.authenticate.temporary_token == true -%}
    {%- assign user_id = chk.u.id -%}
  {%- endif -%}
{%- endif -%}
{%- return user_id -%}

Step 5: Ask for a Link

The reset page sets a session flag. Both pages that receive a post check it, because a post without a valid authenticity token is not refused by Insites: it arrives with an empty session. In the tests, a post with no token, no session cookie, or a made-up token was refused and changed nothing.

app/views/pages/password-reset.liquid

---
slug: password-reset
---
{%- session reset_form = 'on' -%}
{%- if context.params.sent == '1' -%}
  <p>If that address has an account, a reset link is on its way. It works for one hour.</p>
{%- elsif context.params.stale == '1' -%}
  <p>That link has expired or has already been used. Enter your email address for a new one.</p>
{%- endif -%}
<form method='post' action='/password-reset/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 reset link</button>
</form>

The request page sends the same redirect for every address. It allows 5 requests per address and 20 per network address in 15 minutes, and counts every request, whether or not an account has that address.

app/views/pages/password-reset/send.liquid

---
slug: password-reset/send
method: post
layout: ''
---
{%- liquid
  unless context.session.reset_form == 'on'
    redirect_to '/password-reset'
  endunless
  assign email = context.params.email | default: '' | strip | downcase
  assign valid = false
  if email contains '@' and email.size > 5 and email.size < 255
    assign valid = true
  endif
  if email contains ' '
    assign valid = false
  endif
  function ip = 'password_reset/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: 'password_reset', 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 }
  account: users(per_page: 1, filter: { email: { value: $em } }) { results { id } }
}
{%- endgraphql -%}
{%- if valid and look.for_email.total_entries < 5 and look.for_ip.total_entries < 20 -%}
  {%- liquid
    assign row_status = 'miss'
    if look.account.results.size > 0
      assign row_status = 'sent'
    endif
  -%}
  {%- graphql row, t: 'password_reset', f_email: 'email', f_ip: 'ip', f_status: 'status', em: email, ip: ip, st: row_status -%}
  mutation ($t: String!, $f_email: String!, $f_ip: String!, $f_status: String!, $em: String!, $ip: String!, $st: String!) {
    record_create(record: { table: $t, properties: [
      { name: $f_email, value: $em }
      { name: $f_ip, value: $ip }
      { name: $f_status, value: $st }
    ] }) { id }
  }
  {%- endgraphql -%}
  {%- if row_status == 'sent' -%}
    {%- graphql found, em: email, h: 1.0 -%}
    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 link = context.constants.SITE_ORIGIN | append: '/password-reset/link?e=' | append: enc_e | append: '&t=' | append: enc_t
      assign mail = {}
      assign mail.to = email
      assign mail.link = link
    -%}
    {%- graphql sent, tpl: 'password_reset_link', d: mail -%}
    mutation ($tpl: String!, $d: HashObject) {
      email_send(template: { name: $tpl }, data: $d) { is_scheduled_to_send }
    }
    {%- endgraphql -%}
  {%- endif -%}
{%- endif -%}
{%- redirect_to '/password-reset?sent=1' -%}

Send each email through a registered email notification: a file in app/emails/, sent by its name with email_send, with the values it needs built as one Liquid variable and passed as data. An email written inline, with no registered template, answers is_scheduled_to_send: true and sends nothing.

app/emails/password_reset_link.liquid

---
to: '{{ data.to }}'
from: My Site <no-reply@insites.site>
subject: Reset your password
---
<p>Someone asked to reset the password for this address. Use this link to choose a new one. It works for one hour, and stops working once the password has been changed.</p>
<p><a href='{{ data.link | escape }}'>Choose a new password</a></p>
<p>If you did not ask for this, ignore this email. Your password stays as it is.</p>

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.

Step 6: Open the Link

The link page checks the token and shows the form. It changes nothing, so an email scanner that opens the link first does no harm. The referrer tag keeps the token out of the address sent to anything the page loads.

app/views/pages/password-reset/link.liquid

---
slug: password-reset/link
---
{%- session reset_form = 'on' -%}
{%- liquid
  assign em = context.params.e | default: '' | strip | downcase
  assign tok = context.params.t | default: ''
  function user_id = 'password_reset/check_token', email: em, token: tok
-%}
<meta name='referrer' content='no-referrer'>
{%- if user_id == blank -%}
  <p>That link has expired or has already been used. <a href='/password-reset'>Ask for a new one</a>.</p>
{%- else -%}
  {%- if context.params.short == '1' -%}
    <p>Use between 10 and 72 characters.</p>
  {%- endif -%}
  <form method='post' action='/password-reset/save'>
    <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 }}'>
    <label for='password'>New password</label>
    <input id='password' name='password' type='password' autocomplete='new-password' minlength='10' maxlength='72' required>
    <button type='submit'>Save new password</button>
  </form>
{%- endif -%}

Step 7: Save the New Password

The save page checks the session flag and the token again, saves the password with user_update, which ends the token, and signs the person in. redirect_to stops the page, so nothing after a refusal runs. Passwords are capped at 72 characters because Insites checks passwords with bcrypt, which uses only the first 72 bytes.

app/views/pages/password-reset/save.liquid

---
slug: password-reset/save
method: post
layout: ''
---
{%- liquid
  unless context.session.reset_form == 'on'
    redirect_to '/password-reset?stale=1'
  endunless
  assign em = context.params.e | default: '' | strip | downcase
  assign tok = context.params.t | default: ''
  assign pw = context.params.password | default: ''
  function user_id = 'password_reset/check_token', email: em, token: tok
  if user_id == blank
    redirect_to '/password-reset?stale=1'
  endif
  if pw.size < 10 or pw.size > 72
    assign enc_e = em | url_encode
    assign enc_t = tok | url_encode
    assign back = '/password-reset/link?short=1&e=' | append: enc_e | append: '&t=' | append: enc_t
    redirect_to back
  endif
-%}
{%- graphql saved, id: user_id, pw: pw -%}
mutation ($id: ID!, $pw: String!) {
  user_update(id: $id, user: { password: $pw }) { id }
}
{%- endgraphql -%}
{%- liquid
  if saved.user_update.id == blank
    redirect_to '/password-reset?stale=1'
  endif
  sign_in user_id: user_id, timeout_in_minutes: 720
  redirect_to '/'
-%}

Test It

Run every case. Read the token from the email for the first cases, and use your AI assistant to make a short-lived token for the expiry case.

  1. A valid link resets the password. Open the link, save a new password, and you land signed in. Sign out, and the new password signs in.
  2. The same link fails after the reset. Open it again: the page says the link has expired or has already been used, and a save with it is refused.
  3. An expired link fails. Make a token with expires_in: 0.01 (36 seconds), wait a minute, and open its link. It is refused.
  4. One person's link fails for another. Change the address in the link to another account. It is refused.
  5. Two links, one reset. Ask twice, then reset with the second link. The first link is refused afterwards.
  6. An unknown address gets the same answer. The redirect is the same, a miss row is saved, and no email is sent.
  7. The request limits hold. The sixth request for one address in 15 minutes saves no row and sends no email. Requests from one network address stop once 20 rows from it exist in the window.
  8. A changed token fails. Change one character of the token in the link. It is refused.

Also post to the save page with no token, no session cookie, and a made-up token. Each one is refused and the password does not change.

Things to Know

  • Every CRM contact is a user record. This page lets anyone with a user record set a password. If only some people may sign in, check their role as well, as in Email Allow-List.
  • The answer time is close, not equal. In the tests, a known address answered about 40 milliseconds slower than an unknown one, because its email is queued.
  • Do not cache these pages. Ask your AI to send a Cache-Control: no-store header on the reset, link and save pages.
  • A write can fail without stopping the page. Check that each write returned an id, as the save page does with user_update.

A Prompt for Your AI Assistant

Copy this into your AI assistant.

Build password reset on my Insites instance by following
docs.insites.io/developers-guide/password-reset. Read the Insites Logic Engine first,
and never guess.

- Use the token Insites makes: temporary_token(expires_in: 1.0) to send it, and
  authenticate { temporary_token(token:) } to check it. Never store the token.
- Build the link from the SITE_ORIGIN constant, never from the request.
- Give the same answer for every address, and limit requests per address and per
  network address with the client_ip partial on that page.
- Never change anything on a GET. The link page only shows the form.
- Check the session flag on every POST, and check the token again before saving.
- 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.