Your command center to manage instances, permissions and billing.
Instances
An Instance is a virtual server that stores data and runs your application code.
Marketplace
Hosts applications, tools, and various files that you can download and install into an Instance.
Partners
Partners are experts in designing, building, and maintaining apps on Insites.
Support
Contact Insites Support for bugs, feature requests, and software development help.
Instantly available virtual server with built-in
features for your entire team.
CRM
CRM helps manage relationships with customers, suppliers, and third parties efficiently.
Assets
Insites enables you to upload and manage files such as images, and documents using Assets.
CMS
Manage your application content with ease via no-code builders.
Forms
Creating forms for users to input data into the system.
Pipelines
Your instance includes 'Pipelines' to manage opportunities by creating relevant stages.
Permissions
Manage user permissions and security for your application.
Data
Insites lets users view, create, and manage databases and content via the Instance Admin.
Ecommerce
The Insites Ecommerce module provides complete management of ecommerce activities.
Locator
The Locator lets you integrate your instance with Google Maps and customize it as you wish.
Events
Insites Events lets you manage schedules, tickets, and sponsorship on your instance.
API
Integrate with any tool or platform with bespoke Endpoints.
AI
Take advantage of the latest AI technology with your data.
A cohesive set of guidelines, patterns, and assets for a consistent, user-centered design.
Design System Overview
The Insites design system is a collection of reusable components, guided by clear standards.
Component Hierarchy
Learn how to customize components and how to structure your designs so they inherit attributes.
Font Icons
A full suite of font icons to use across your project within menus, buttons and quick links.
Color Styles
Figma color styles represent variables for Insites components, enabling quick customization.
Build blazing-fast, consistent user interfaces with our web component library.
General
A wide variety or general use components such as buttons, carousel, gallery, headings, and loaders.
Layout
Useful layout components such as an accordion, backdrop, drawer, headers, modals, and more.
Data Entry
Collect data with components that include card select, checkbox, inputs, sliders, and editors.
Data Display
Create engaging interfaces with components such as Kanban boards, charts, tables, and timelines.
Tutorials, references, and examples on how to build modern web applications on Insites.
Development
Covers key topics for setting up and maintaining web applications on Insites.
Modules
Modules enable code reuse and sharing while protecting creators' intellectual property.
Pages and Layouts
Learn how to implement pages and rendering content on your Instance.
Databases and Users
Discover how to create custom data models, import/export data and manage users.
The Insites CLI Tool helps you deploy configuration
files and assets to your Insites Instance.
Get Started
Guides you through the requirements and steps to install and start using the Insites CLI Tool.
Commands and Options
Learn the commands and options for managing configurations in the Insites CLI.
Graphical User Interface
Discover how using the GUI can enhance your workflow by simplifying complex processes.
Code Linting
Automatically check your codebase for programmatic and stylistic errors when deploying.
GraphQL
A data query and manipulation language that allows you to specify the data you require.
Liquid
Liquid is a template language for creating dynamic pages, content and configurations.
API Docs
Learn about the functionalities and structure of the inbuilt API Endpoints of your instance.
Web Applications
Discover how to create your web application with step by step guides and helpful examples.
Your command center to manage instances, permissions and billing.
Instances
An Instance is a virtual server that stores data and runs your application code.
Marketplace
Hosts applications, tools, and various files that you can download and install into an Instance.
Partners
Partners are experts in designing, building, and maintaining apps on Insites.
Support
Contact Insites Support for bugs, feature requests, and software development help.
Instantly available virtual server with built-in
features for your entire team.
CRM
CRM helps manage relationships with customers, suppliers, and third parties efficiently.
Assets
Insites enables you to upload and manage files such as images, and documents using Assets.
CMS
Manage your application content with ease via no-code builders.
Forms
Creating forms for users to input data into the system.
Pipelines
Your instance includes 'Pipelines' to manage opportunities by creating relevant stages.
Permissions
Manage user permissions and security for your application.
Data
Insites lets users view, create, and manage databases and content via the Instance Admin.
Ecommerce
The Insites Ecommerce module provides complete management of ecommerce activities.
Locator
The Locator lets you integrate your instance with Google Maps and customize it as you wish.
Events
Insites Events lets you manage schedules, tickets, and sponsorship on your instance.
API
Integrate with any tool or platform with bespoke Endpoints.
AI
Take advantage of the latest AI technology with your data.
A cohesive set of guidelines, patterns, and assets for a consistent, user-centered design.
Design System Overview
The Insites design system is a collection of reusable components, guided by clear standards.
Component Hierarchy
Learn how to customize components and how to structure your designs so they inherit attributes.
Font Icons
A full suite of font icons to use across your project within menus, buttons and quick links.
Color Styles
Figma color styles represent variables for Insites components, enabling quick customization.
Build blazing-fast, consistent user interfaces with our web component library.
General
A wide variety or general use components such as buttons, carousel, gallery, headings, and loaders.
Layout
Useful layout components such as an accordion, backdrop, drawer, headers, modals, and more.
Data Entry
Collect data with components that include card select, checkbox, inputs, sliders, and editors.
Data Display
Create engaging interfaces with components such as Kanban boards, charts, tables, and timelines.
Tutorials, references, and examples on how to build modern web applications on Insites.
Development
Covers key topics for setting up and maintaining web applications on Insites.
Modules
Modules enable code reuse and sharing while protecting creators' intellectual property.
Pages and Layouts
Learn how to implement pages and rendering content on your Instance.
Databases and Users
Discover how to create custom data models, import/export data and manage users.
The Insites CLI Tool helps you deploy configuration
files and assets to your Insites Instance.
Get Started
Guides you through the requirements and steps to install and start using the Insites CLI Tool.
Commands and Options
Learn the commands and options for managing configurations in the Insites CLI.
Graphical User Interface
Discover how using the GUI can enhance your workflow by simplifying complex processes.
Code Linting
Automatically check your codebase for programmatic and stylistic errors when deploying.
GraphQL
A data query and manipulation language that allows you to specify the data you require.
Liquid
Liquid is a template language for creating dynamic pages, content and configurations.
API Docs
Learn about the functionalities and structure of the inbuilt API Endpoints of your instance.
Web Applications
Discover how to create your web application with step by step guides and helpful examples.
Your command center to manage instances, permissions and billing.
Instances
An Instance is a virtual server that stores data and runs your application code.
Marketplace
Hosts applications, tools, and various files that you can download and install into an Instance.
Partners
Partners are experts in designing, building, and maintaining apps on Insites.
Support
Contact Insites Support for bugs, feature requests, and software development help.
Instantly available virtual server with built-in features for your entire team.
CRM
CRM helps manage relationships with customers, suppliers, and third parties efficiently.
Assets
Insites enables you to upload and manage files such as images, and documents using Assets.
CMS
Manage your application content with ease via no-code builders.
Forms
Creating forms for users to input data into the system.
Pipelines
Your instance includes 'Pipelines' to manage opportunities by creating relevant stages.
Permissions
Manage user permissions and security for your application.
Data
Insites lets users view, create, and manage databases and content via the Instance Admin.
Ecommerce
The Insites Ecommerce module provides complete management of ecommerce activities.
Locator
The Locator lets you integrate your instance with Google Maps and customize it as you wish.
Events
Insites Events lets you manage schedules, tickets, and sponsorship on your instance.
API
Integrate with any tool or platform with bespoke Endpoints.
AI alpha
Take advantage of the latest AI technology with your data.
A cohesive set of guidelines, patterns, and assets for a consistent, user-centered design.
Design System Overview
The Insites design system is a collection of reusable components, guided by clear standards.
Component Hierarchy
Learn how to customize components and how to structure your designs so they inherit attributes.
Font Icons
A full suite of font icons to use across your project within menus, buttons and quick links.
Color Styles
Figma color styles represent variables for Insites components, enabling quick customization.
Build blazing-fast, consistent user interfaces with our web component library.
General
A wide variety or general use components such as buttons, carousel, gallery, headings, and loaders.
Layout
Useful layout components such as an accordion, backdrop, drawer, headers, modals, and more.
Data Entry
Collect data with components that include card select, checkbox, inputs, sliders, and editors.
Data Display
Create engaging interfaces with components such as Kanban boards, charts, tables, and timelines.
Tutorials, references, and examples on how to build modern web applications on Insites.
Development
Covers key topics for setting up and maintaining web applications on Insites.
Modules
Modules enable code reuse and sharing while protecting creators' intellectual property.
Pages and Layouts
Learn how to implement pages and rendering content on your Instance.
Databases and Users
Discover how to create custom data models, import/export data and manage users.
The Insites CLI Tool helps you deploy configuration
files and assets to your Insites Instance.
Get Started
Guides you through the requirements and steps to install and start using the Insites CLI Tool.
Commands and Options
Learn the commands and options for managing configurations in the Insites CLI.
Graphical User Interface
Discover how using the GUI can enhance your workflow by simplifying complex processes.
Code Linting
Automatically check your codebase for programmatic and stylistic errors when deploying.
GraphQL
A data query and manipulation language that allows you to specify the data you require.
Liquid
Liquid is a template language for creating dynamic pages, content and configurations.
API Docs
Learn about the functionalities and structure of the inbuilt API Endpoints of your instance.
Web Applications
Discover how to create your web application with step by step guides and helpful examples.
Sign people in with an emailed link or a 6-digit code, with limits, safe confirmation and tests.

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:
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.
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.
sign_in tag.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.
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.
X-Forwarded-For. It is an internal address that changes from request to request. A limit on it counts per server, not per visitor.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.
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 kept | Test | Result |
|---|---|---|
| Read the count, write the count plus one | 16 wrong guesses at once, limit 5 | 14 guesses were checked |
| Read the count, write the count plus one | 8 right codes at once | 2 sign-ins from one code |
Add one with increment | 16 right codes at once | 2 sign-ins from one code |
One row per slot, with a unique external_id | 16 wrong guesses at once, limit 5 | 5 checked, 11 refused |
One row per slot, with a unique external_id | 16, 8 and 4 right codes at once | Exactly 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.
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 -%}
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.
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.
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.
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.
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>
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 -%}
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'
-%}
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
-%}
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 '/' -%}
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.
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).
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
-%}
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>
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 }}
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 }}
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>
| Limit | Value on this page |
|---|---|
| Link lifetime | 15 minutes |
| Code lifetime | 10 minutes |
| Requests per email address | 5 in 15 minutes |
| Requests per network address | 20 in 15 minutes |
| Wrong guesses per code | 5, then the code is killed |
| Uses per link or code | 1 |
| Sign-in length | 720 minutes (12 hours) |
Run every case. For the link, read each code case as the matching link case.
ok, and a page that shows context.current_user shows the person. (Link: Continue signs in.)invalid, and nobody is signed in.too_many. The right code then also answers too_many, and the row is killed.expired. Then put the lifetime back.invalid. (Link: open a used link, and you are sent back to the sign-in page.)invalid.code_sent, a miss row is saved, and no email is sent.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.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.
invalid.Cache-Control: no-store header on the sign-in, link and endpoint pages.errors key. Pass every field value as text, and check that each write returned an id or a count.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.
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.