> For the complete documentation index, see [llms.txt](https://docs.ipcheck.ing/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.ipcheck.ing/developer/configuration/security-options.md).

# Security Options

MyIP's backend proxies a number of third-party APIs, some of them paid. Left wide open, a public instance is an anonymous free proxy for whoever finds it.

Think of protection as two layers, in this order:

1. **The edge (recommended primary layer).** A CDN/WAF in front of your origin — Cloudflare is the worked example below — blocks abusive traffic before it ever reaches your server, with far better tooling than any application can ship: managed bot detection, flexible rate-limiting rules, challenges, and a global view of attacker IPs.
2. **The app itself (the safety net).** Four environment variables build a last line of defence into the backend. They are worth setting even behind an edge — they are what protects you when someone finds your origin's real address and bypasses the edge entirely — but they are a backstop, not the primary defence.

## Protect at the edge first (recommended)

Any CDN/WAF with per-IP rate rules works the same way; the steps below use Cloudflare because it's the most common choice and its free plan already covers the essentials.

{% stepper %}
{% step %}

#### Proxy your DNS records through Cloudflare

In the Cloudflare DNS panel, keep the record for your MyIP hostname **Proxied** (orange cloud), so all traffic enters through Cloudflare's edge. MyIP is built for this: the backend already reads `CF-Connecting-IP` to identify real client IPs, and its cacheable `/api/*` routes get served from the edge cache, which absorbs a large share of load before it reaches you.
{% endstep %}

{% step %}

#### Add a rate-limiting rule for `/api/*`

Under **Security → WAF → Rate limiting rules**, create a rule matching your API path — for example, expression `(http.request.uri.path wildcard "/api/*")` — and block the source IP when it exceeds your threshold. Rate limiting is available on every plan; rule counts and window options vary by plan. Because the edge cache already answers repeat lookups, legitimate visitors rarely need high request volumes — start stricter than you think, and loosen if real users complain.
{% endstep %}

{% step %}

#### Turn on bot protection

Enable **Bot Fight Mode** (Security → Bots). Most abuse of a public MyIP instance is scripted scraping of the geolocation endpoints, which is exactly what this targets. If your plan has custom WAF rules, a **Managed Challenge** on non-browser traffic to `/api/*` is a gentler alternative that never hard-blocks a real user.
{% endstep %}

{% step %}

#### Lock your origin down

Edge rules only help if traffic can't skip the edge. Configure your origin server's firewall to accept HTTP(S) only from [Cloudflare's IP ranges](https://www.cloudflare.com/ips/) — or take the origin off the public internet entirely with Cloudflare Tunnel. If the origin answers direct requests, an attacker who discovers its address bypasses every rule above; this is exactly the scenario the app-level variables below exist for.
{% endstep %}
{% endstepper %}

{% hint style="info" %}
Requests served from Cloudflare's cache never reach the origin, so they are invisible to the app-level limiters below — another reason the edge is where volume control belongs.
{% endhint %}

## The app's own safety net

Four environment variables build the backstop layer into the backend. All four are optional and off (or permissive) by default.

| Variable                           | Purpose                            | Default          |
| ---------------------------------- | ---------------------------------- | ---------------- |
| `ALLOWED_DOMAINS`                  | Which sites may call `/api/*`      | `localhost` only |
| `SECURITY_RATE_LIMIT`              | Hard request cap per IP            | `0` — disabled   |
| `SECURITY_DELAY_AFTER`             | Progressive slow-down per IP       | `0` — disabled   |
| `SECURITY_BLACKLIST_LOG_FILE_PATH` | On-disk ledger of rate-limited IPs | empty — no file  |

## `ALLOWED_DOMAINS` — the referer gate

Every `/api/*` route passes through one referer check. The backend reads the `Referer` header, extracts its **hostname**, and requires that hostname to be on the allowlist.

The allowlist is `localhost` plus the comma-separated entries in `ALLOWED_DOMAINS`.

Rejections are `403`:

| Situation                                             | Response                           |
| ----------------------------------------------------- | ---------------------------------- |
| No `Referer` header at all                            | `{"error": "What are you doing?"}` |
| Referer present, hostname not allowed (or unparsable) | `{"error": "Access denied"}`       |

{% hint style="danger" %}
**If you serve MyIP on a real domain, you must set this.** With `ALLOWED_DOMAINS` empty, only `localhost` passes — so a deployment reached at `https://ip.example.com` or `http://192.168.1.10:18966` gets `403` on every API call, and the app looks broken while the static page loads fine.
{% endhint %}

Rules to keep in mind:

* **Hostnames only.** No scheme, no port, no path: `example.com`, not `https://example.com:443/`.
* **Exact matches.** `example.com` does not cover `www.example.com`. List both.
* **IP addresses count as hostnames.** Reaching the app at `http://192.168.1.10:18966` means adding `192.168.1.10`.
* **`localhost` is always allowed**, so local development never needs configuration.

{% code title=".env" %}

```bash
ALLOWED_DOMAINS="example.com,www.example.com,ip.example.com"
```

{% endcode %}

{% hint style="warning" %}
The referer check is an abuse deterrent, not authentication. Any client can send an arbitrary `Referer` header. It stops casual hotlinking and drive-by scripts; it does not stop a determined scraper. Pair it with rate limiting.
{% endhint %}

Behind a reverse proxy, make sure your proxy forwards the `Referer` header untouched. See [Reverse Proxy & Domains](/developer/getting-started/reverse-proxy-and-domains.md).

## `SECURITY_RATE_LIMIT` — hard cap

A per-IP request cap on `/api/*`, backed by `express-rate-limit`.

* **Window**: 20 minutes, rolling.
* **Limit**: the number you set. `0` or empty means the limiter is never mounted at all.
* **Over the limit**: `429` with `{"message": "Too Many Requests"}`.
* **Exempt route**: `/api/monitoring`, the Sentry tunnel, which has its own limiter — see [Error Monitoring](/developer/configuration/error-monitoring.md).

{% hint style="info" %}
The startup line prints `🛡️ Rate limiter enabled — N requests per 60 minutes`. The window enforced in code is **20 minutes**.
{% endhint %}

### What gets logged

The backend logs the moment an IP crosses the threshold — **once**, on the transition, not on every subsequent blocked request. That keeps an abusive client hammering a limited endpoint from flooding your logs.

```
WARN: IP rate-limited
    ip: "203.0.113.45"
```

When a Sentry backend DSN is configured, that same line is mirrored to Sentry.

### How the client IP is determined

The limiter and the log line resolve the caller's IP in this order:

1. `CF-Connecting-IP`
2. the first entry of `X-Forwarded-For`
3. `CF-Connecting-IPv6`
4. the socket address Express sees

The app runs with `trust proxy` set to `1`, meaning it trusts exactly one hop of proxy headers.

{% hint style="warning" %}
If your reverse proxy does not set `X-Forwarded-For`, every request looks like it comes from the proxy — one IP, one shared quota, and the first busy visitor locks out everyone. Verify header forwarding before turning the limiter on.
{% endhint %}

## `SECURITY_DELAY_AFTER` — progressive slow-down

A gentler companion, backed by `express-slow-down`. Instead of rejecting, it delays.

* **Window**: 60 minutes, rolling.
* **Free requests**: the number you set. Requests beyond it are answered slowly.
* **Delay**: `400 ms × total requests made in the window`.
* `0` or empty means the middleware is never mounted.
* Same `/api/monitoring` exemption.

{% hint style="warning" %}
The delay is computed from the **total** hit count, not from the overage — so it starts high and climbs fast. With `SECURITY_DELAY_AFTER="40"`, request 41 already waits about 16 seconds; with `"100"`, request 101 waits about 40 seconds. Pick a value knowing the first throttled request is already a long wait, and expect client-side timeouts beyond it.
{% endhint %}

Slow-down and rate limit stack. Treat `SECURITY_RATE_LIMIT` as the main defence and reach for the slow-down only when you want scripted bursts to grind rather than fail.

## `SECURITY_BLACKLIST_LOG_FILE_PATH` — on-disk ledger

Opt-in. When set, every rate-limit transition is also appended to a plain-text file.

{% code title=".env" %}

```bash
SECURITY_BLACKLIST_LOG_FILE_PATH="logs/blacklist-ip.log"
```

{% endcode %}

* The path is resolved **relative to the application root**. Missing directories are created.
* One CSV line per IP: `ip,count,first-seen-timestamp`.
* The timestamp is the host-local time with an explicit UTC offset, e.g. `2026-07-14 10:23:45 +0800`.
* On a repeat offender the **count increments and the original timestamp stays**, so you can see when an IP first showed up.

```
203.0.113.45,7,2026-07-14 10:23:45 +0800
198.51.100.9,1,2026-07-15 02:11:07 +0800
```

Leaving it empty changes nothing about enforcement — the warn log still fires. The file exists for deployments that want a permanent record, for example to feed a firewall or fail2ban-style script.

{% hint style="info" %}
In Docker, write the ledger to a mounted volume. A path inside the container's writable layer disappears when the container is recreated.
{% endhint %}

## Middleware order

Worth knowing when you debug a `403` or a `429`:

1. HTTP request logging, if `LOG_HTTP=true` — so 429s appear in the log
2. Rate limiter (if enabled)
3. Slow-down (if enabled)
4. JSON body parsing
5. Referer gate
6. The route handler

Rate limiting therefore happens **before** the referer check. A flood of requests with a bad referer still consumes the offender's quota — which is the desired behaviour.

Also note that a CDN in front of the app serves cached `/api/*` responses without ever reaching the origin, so those requests are invisible to the limiter. Most read-heavy routes are edge-cacheable.

## Recommended backstop values for a public instance

Even with the edge configured, set these so the origin can defend itself on its own:

{% tabs %}
{% tab title="Node (.env)" %}
{% code title=".env" %}

```bash
ALLOWED_DOMAINS="example.com,www.example.com"
SECURITY_RATE_LIMIT="600"
SECURITY_BLACKLIST_LOG_FILE_PATH="logs/blacklist-ip.log"
```

{% endcode %}
{% endtab %}

{% tab title="Docker" %}

```bash
docker run -d -p 18966:18966 \
  -e ALLOWED_DOMAINS="example.com,www.example.com" \
  -e SECURITY_RATE_LIMIT="600" \
  -e SECURITY_BLACKLIST_LOG_FILE_PATH="logs/blacklist-ip.log" \
  -v "$(pwd)/logs:/app/logs" \
  --name myip \
  jason5ng32/myip:latest
```

{% endtab %}
{% endtabs %}

Those numbers are a starting point, not a rule. Calibrate them:

* One full page load fires **many** `/api/*` calls — IP sources, connectivity checks, DNS probes. A single visitor easily spends dozens of requests per session.
* Set the limit too low and normal users hit `429` mid-diagnosis.
* Watch the ledger and the `IP rate-limited` warnings for a week, then tighten.
* Behind CGNAT or a corporate NAT, many real users share one IP. Leave headroom.

{% hint style="success" %}
The layering in one sentence: let the edge absorb and filter the volume, and size these variables generously enough that they only ever fire on traffic that slipped past it.
{% endhint %}

## Related pages

* [Environment Variables](/developer/reference/environment-variables.md) — the full list
* [Reverse Proxy & Domains](/developer/getting-started/reverse-proxy-and-domains.md) — headers your proxy must forward
* [Logging](/developer/configuration/logging.md) — where the warnings show up
* [Optional API Keys](/developer/configuration/optional-api-keys.md) — the quotas you are protecting


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://docs.ipcheck.ing/developer/configuration/security-options.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
