Rate Limiting by IP

Last updated on October 10, 2026.

Count attempts per visitor address, refuse with too_many, and lock out repeat failures safely.

Rate limiting stops one visitor from trying again and again, for example guessing sign-in codes. On Insites you build it with two tables and a few lines of Liquid. You count failed attempts per visitor address over a time window. When there are too many, you refuse with too_many. After repeated failures you lock the address out, and each lockout is longer than the last.

What You Need Before You Start

  • An Insites instance you can deploy to, through Insites CloudShell or the Insites GitHub App.
  • The page you want to protect, such as a sign-in code endpoint. See Passwordless Sign-In.
  • Your limits. This page uses 5 failures in 15 minutes, a first lockout of 15 minutes, and a longest lockout of 24 hours.

Read the Visitor Address

Request headers are in context.headers. They are not stored under their HTTP names. Each name is uppercased, every hyphen becomes an underscore, and HTTP_ is added in front. So X-Forwarded-For is read as context.headers.HTTP_X_FORWARDED_FOR. A read such as context.headers['X-Forwarded-For'] is always blank.

X-Forwarded-For can hold several addresses, separated by commas. The visitor address is the first entry. It is the same value as X-Real-IP, read as context.headers.HTTP_X_REAL_IP. 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.

Read the address in one partial, and call it from every page that needs it:

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
-%}
{% liquid
  function ip = 'client_ip'
%}
<p>Your address: {{ ip | escape }}</p>

Step 1: Create Two Tables

One row in login_attempt is one failed attempt. One row in ip_lockout is one lockout.

app/schema/login_attempt.yml

name: login_attempt
properties:
  - name: ip
    type: string

app/schema/ip_lockout.yml

name: ip_lockout
properties:
  - name: ip
    type: string
  - name: minutes
    type: integer

Every record gets created_at automatically, so neither table needs a time field.

Step 2: Add Two GraphQL Files

Table and field names are passed in as variables, not written into the query.

app/graphql/records/find_by.graphql

query find_by($table: String!, $field: String!, $value: String!, $limit: Int!) {
  records(
    per_page: $limit
    filter: { table: { value: $table }, properties: [{ name: $field, value: $value }] }
    sort: [{ created_at: { order: DESC } }]
  ) {
    total_entries
    results { id created_at properties }
  }
}

app/graphql/records/create.graphql

mutation create($table: String!, $properties: [PropertyInputType]) {
  record_create(record: { table: $table, properties: $properties }) { id }
}

Step 3: Check Before You Do Anything

This partial answers true when the address is locked out or has too many recent failures. Field values come back under properties, as text, so plus: 0 turns minutes into a number.

app/views/partials/rate_limit/blocked.liquid

{% liquid
  assign blocked = false

  graphql locks = 'records/find_by', table: 'ip_lockout', field: 'ip', value: ip, limit: 1
  assign lock = locks.records.results.first
  if lock
    assign age = lock.created_at | time_diff: 'now', 's'
    assign minutes = lock.properties.minutes | plus: 0
    assign lock_seconds = minutes | times: 60
    if age < lock_seconds
      assign blocked = true
    endif
  endif

  graphql recent = 'records/find_by', table: 'login_attempt', field: 'ip', value: ip, limit: 5
  if recent.records.results.size >= 5
    assign oldest = recent.records.results | last
    assign age = oldest.created_at | time_diff: 'now', 's'
    if age < 900
      assign blocked = true
    endif
  endif

  return blocked
%}

Call it at the top of the page you protect, and refuse with status 429:

{% liquid
  function ip = 'client_ip'
  function blocked = 'rate_limit/blocked', ip: ip
  if blocked
    response_status 429
    assign body = {}
    assign body.error = 'too_many'
    assign out = body | json
    echo out
    break
  endif
%}

Step 4: Record Each Failure and Lock Out

Call this partial whenever an attempt fails, for example when a sign-in code is wrong. It saves the failure. When the address reaches 5 failures in 15 minutes, it adds a lockout. The first lockout is 15 minutes. Each later lockout for the same address is twice as long, up to 24 hours.

app/views/partials/rate_limit/record_failure.liquid

{% liquid
  assign attempt_ip = {}
  assign attempt_ip.name = 'ip'
  assign attempt_ip.value = ip
  assign attempt_props = []
  assign attempt_props = attempt_props | add_to_array: attempt_ip
  graphql saved = 'records/create', table: 'login_attempt', properties: attempt_props

  graphql recent = 'records/find_by', table: 'login_attempt', field: 'ip', value: ip, limit: 5
  if recent.records.results.size < 5
    return false
  endif
  assign oldest = recent.records.results | last
  assign age = oldest.created_at | time_diff: 'now', 's'
  if age >= 900
    return false
  endif

  graphql earlier = 'records/find_by', table: 'ip_lockout', field: 'ip', value: ip, limit: 1
  assign lockouts_before = earlier.records.total_entries
  assign minutes = 15
  for i in (1..lockouts_before)
    assign minutes = minutes | times: 2
    if minutes >= 1440
      assign minutes = 1440
      break
    endif
  endfor

  assign lock_ip = {}
  assign lock_ip.name = 'ip'
  assign lock_ip.value = ip
  assign lock_minutes = {}
  assign lock_minutes.name = 'minutes'
  assign lock_minutes.value_int = minutes
  assign lock_props = []
  assign lock_props = lock_props | add_to_array: lock_ip | add_to_array: lock_minutes
  graphql locked = 'records/create', table: 'ip_lockout', properties: lock_props
  return true
%}
{% function locked = 'rate_limit/record_failure', ip: ip %}

Step 5: Test It

  1. Fail 5 times in a row. The sixth request answers 429 with too_many.
  2. Wait 15 minutes. Requests work again.
  3. Fail 5 more times. The new lockout lasts 30 minutes.

Things to Know

  • Check first, then do the work. If you check after sending an email or checking a code, the attempt has already happened.
  • This check is good for slowing down a visitor, not for a hard limit. Requests that arrive at the same moment all read the count before any of them adds a row, so a burst can get past it. To hold a limit such as five guesses exactly, claim one slot per attempt. See Step 3, Count Attempts So They Cannot Race, in Passwordless Sign-In.
  • A deleted record is kept for 30 days before it is removed. To clear an address by hand, delete its rows. The records query stops returning them straight away.

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.