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.
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.
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:
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.
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
-%}
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).
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.
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 -%}
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.
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 -%}
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 '/'
-%}
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.
expires_in: 0.01 (36 seconds), wait a minute, and open its link. It is refused.miss row is saved, and no email is sent.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.
Cache-Control: no-store header on the reset, link and save pages.id, as the save page does with user_update.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.
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.