{"id":50996458,"url":"https://github.com/codeniko/nginx-ip-gate","last_synced_at":"2026-06-20T10:01:40.337Z","repository":{"id":355468311,"uuid":"1227429370","full_name":"codeniko/nginx-ip-gate","owner":"codeniko","description":"Tiny self-hosted Nginx auth backend that allowlists your public IP after a login - so smart TVs and casting devices on your home network just work.","archived":false,"fork":false,"pushed_at":"2026-05-03T18:28:49.000Z","size":108,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-05-03T20:29:13.208Z","etag":null,"topics":["auth-request","ddns","docker","home-server","ip-allowlist","jellyfin","nginx","nodejs","reverse-proxy","self-hosted"],"latest_commit_sha":null,"homepage":"","language":"JavaScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/codeniko.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-05-02T17:09:45.000Z","updated_at":"2026-05-03T18:28:52.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/codeniko/nginx-ip-gate","commit_stats":null,"previous_names":["codeniko/nginx-ip-gate"],"tags_count":null,"template":false,"template_full_name":null,"purl":"pkg:github/codeniko/nginx-ip-gate","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/codeniko%2Fnginx-ip-gate","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/codeniko%2Fnginx-ip-gate/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/codeniko%2Fnginx-ip-gate/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/codeniko%2Fnginx-ip-gate/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/codeniko","download_url":"https://codeload.github.com/codeniko/nginx-ip-gate/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/codeniko%2Fnginx-ip-gate/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34565244,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-20T02:00:06.407Z","response_time":98,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["auth-request","ddns","docker","home-server","ip-allowlist","jellyfin","nginx","nodejs","reverse-proxy","self-hosted"],"created_at":"2026-06-20T10:01:36.568Z","updated_at":"2026-06-20T10:01:40.328Z","avatar_url":"https://github.com/codeniko.png","language":"JavaScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# nginx-ip-gate\n\nTiny self-hosted Nginx auth backend that allowlists your public IP after a login — so smart TVs and casting devices on your home network just work. Log in once from your phone via a small form; every device behind the same NAT inherits access until the session expires.\n\nBuilt around Nginx's [`auth_request`](https://nginx.org/en/docs/http/ngx_http_auth_request_module.html) module; inspired by [`zuavra/nginx-ip-whitelister`](https://github.com/zuavra/nginx-ip-whitelister).\n\n\u003e **Be mindful using this from public WiFi, hotels, cafés, cellular networks, or work — it allows every device on that network to access your apps. Only run behind HTTPS.\n\n\u003cvideo src=\"https://github.com/user-attachments/assets/846e660f-05e4-4a3d-afc6-b144e6a969f1\" width=\"300\" autoplay loop muted playsinline\u003e\u003c/video\u003e\n\n\n## How it works\n\n```\n                 not allowlisted              allowlisted\n                  ┌───────────────┐            ┌───────────────┐\n   Browser ──┬──\u003e │ Nginx         │            │ Nginx         │ ──\u003e upstream app\n             │    │ + auth_request│            │ + auth_request│\n             │    │   ↓           │            │   ↓ /verify   │\n             │    │   /verify 401 │            │   200 OK      │\n             │    └───────┬───────┘            └───────────────┘\n             │            │ 302 to /gate?next=\u003coriginal-url\u003e\n             │            ↓\n             │     ┌───────────────┐    POST /gate    ┌───────────────┐\n             └─────│ /gate (form)  │ ───────────────\u003e │ allowlist.add │\n                   └───────────────┘                  │ → 302 to next │\n                                                      └───────────────┘\n        (after login, the browser retries \u003coriginal-url\u003e and hits the allowlisted path)\n```\n\nFor each request to a protected app, Nginx fires a subrequest to `/verify`. If the requester's IP is in the in-memory allowlist, the subrequest returns 200 and the original request proceeds to the upstream. If not, Nginx 302s the user to `/gate?next=\u003coriginal-url\u003e`, which serves a login form. POSTing valid creds adds the IP to the allowlist and 302s back to `next`.\n\nThe allowlist is **single, shared, and lives in RAM** — one login covers every gated app behind this Nginx. Entries expire on a configurable timeout policy (fixed cap from login + sliding window of inactivity, either or both). A periodic sweep evicts expired entries to bound memory growth from never-revisited IPs (e.g. dynamic ISP leases).\n\n## Setup\n\n```sh\nnpm ci\n\n# Generate a bcrypt hash for your password\nnpm run hashpw -- mySecret\n# → $2a$10$...\n\n# Create users.json with that hash. Format is a flat {username: hash} map:\n#   {\n#     \"alice\": \"$2a$10$...\"\n#   }\n\nnode index.js          # production\nnpm run dev            # local browser testing (sets TRUST_REMOTE_ADDR=yes)\n```\n\n`users.json` is gitignored. `.env` is committed with sensible defaults — edit it in place to change ports/timeouts. Comment a timeout out to disable just that one (at least one of `FIXED_TIMEOUT`/`SLIDING_TIMEOUT` must remain set).\n\nUse `npm run dev` when you want to fill in the form via `http://localhost:8350` without an Nginx in front; it sets `TRUST_REMOTE_ADDR=yes` for that one process so the handlers fall back to the connection's source IP. **Don't use it in production** — see the env vars table below.\n\n## Endpoints\n\n| Method | Path         | Purpose                                                                                |\n| ------ | ------------ | -------------------------------------------------------------------------------------- |\n| GET    | `/gate`      | Login form. Honours `?next=\u003csafe-relative-url\u003e` and embeds it as a hidden form field.  |\n| POST   | `/gate`      | Verify creds, allowlist `X-Forwarded-For`. With safe `next`: 302 there. Without: 200.  |\n| GET    | `/heartbeat` | Verify Basic-Auth creds, allowlist `X-Forwarded-For`. For routers/cron/automations.    |\n| GET    | `/verify`    | `auth_request` target — 200 if IP allowlisted, 401 otherwise                           |\n| GET    | `/deauth`    | Remove `X-Forwarded-For` from the allowlist (no auth required)                         |\n| GET    | `/health`    | Liveness probe — always 200, no auth, no logging. Used by the Docker HEALTHCHECK.      |\n\n`POST /gate`, `/verify`, `/deauth`, and `/heartbeat` all read `X-Forwarded-For` to identify the client. Nginx must be configured to set it (`proxy_set_header X-Forwarded-For $remote_addr;` — see `examples/`). `GET /gate` (form rendering) and `/health` (liveness probe) don't need it.\n\nFor local browser testing without an Nginx in front, set `TRUST_REMOTE_ADDR=yes` (or just run `npm run dev`). The handlers fall back to `req.socket.remoteAddress` when `X-Forwarded-For` is missing. **Don't enable this in production** — it's the only way silent misconfiguration could allowlist Nginx itself.\n\n### `/heartbeat` for routers and automations\n\nA periodic credentialed ping that keeps an IP in the allowlist without anyone touching the form. Authenticate with HTTP Basic Auth (same `users.json` credentials as `/gate`):\n\n```sh\ncurl -u alice:hunter2 https://example.com/heartbeat\n# good 1.2.3.4       ← IP was newly allowlisted\n# nochg 1.2.3.4      ← IP was already alive; lastModifiedAt refreshed\n```\n\nIn a router's \"Custom DDNS\" UI: server URL `https://example.com/heartbeat`, username/password from `users.json`, update interval comfortably less than `SLIDING_TIMEOUT` (e.g. 5–10 min if sliding is 30m). The router never sees a redirect — responses are always small `text/plain` bodies. Make sure your router has hairpin NAT enabled (almost all do by default) so the request actually goes back through Nginx and gets the right `X-Forwarded-For`.\n\n### Returning users to the URL they were trying to reach\n\nWhen Nginx 401s an unauthenticated user, the example configs redirect to `/gate?next=$request_uri`. The gate validates `next` (must be a same-host relative path), embeds it as a hidden form field, and on successful login 302s back to it. The hidden field is sent in the POST body — not the URL — so configurations that strip query strings on POST (some Nginx rewrites, CDNs, WAFs) don't break the redirect.\n\n**Open-redirect protection.** `next` is only honored if it starts with a single `/`, doesn't start with `//`, contains no backslashes, and is ≤ 1024 chars. Anything unsafe is silently collapsed to empty — same UX as no `next` at all (form authenticates, doors open, no redirect). Validation runs on both GET and POST.\n\n**Known limitation.** Nginx interpolates `$request_uri` raw into the redirect URL without URL-encoding, so original URLs with `\u0026`-separated query parameters (e.g. `/app1?a=1\u0026b=2`) lose everything after the first `\u0026` when round-tripped through `next`. Single-`?` query strings roundtrip fine.\n\n## Nginx config\n\nSee `examples/nginx-host-based.conf` (one server block per app), `examples/nginx-path-based.conf` (multiple apps on different paths in one server block), and `examples/nginx-http-ip-gate.conf` (optional http-level rate-limit snippet — see below).\n\n### Rate limiting `/gate` and `/heartbeat` (opt-in)\n\nEach POST to `/gate` or hit on `/heartbeat` runs a bcrypt compare (~100ms). Without a limit, async-fired requests can both brute-force credentials AND tie up the gate's event loop, stalling legitimate `/verify` traffic. The example configs include `limit_req` directives for this protection, but they're **commented out by default** so a fresh install passes `nginx -t` with no extra setup. Two steps to enable:\n\n**1. Install the http-block snippet** (once). The `limit_req_zone` directive must live at the `http {}` level, not inside any server block, so it ships as a separate file:\n\n```sh\nsudo cp examples/nginx-http-ip-gate.conf /etc/nginx/conf.d/\n```\n\nThat snippet contains one line:\n\n```nginx\nlimit_req_zone $binary_remote_addr zone=gate_login:10m rate=10r/m;\n```\n\nIt defines *what* the limit is: a per-IP token bucket refilling at 10 requests per minute. `10m` of shared memory holds ~160k unique IP entries.\n\n**2. Uncomment the `limit_req` lines** in your server-block config. Look for two lines that match `# limit_req zone=gate_login burst=5 nodelay;` (one in `/gate`, one in `/heartbeat`) and remove the leading `#`. Then:\n\n```sh\nsudo nginx -t \u0026\u0026 sudo systemctl reload nginx\n```\n\nIf you uncomment the `limit_req` lines without installing the snippet, `nginx -t` will fail with `\"zero size shared memory zone gate_login\"`.\n\nThe `burst=5 nodelay` parameters matter:\n\n| Config                                      | Behavior                                                                                              |\n| ------------------------------------------- | ----------------------------------------------------------------------------------------------------- |\n| `limit_req zone=z;` (no burst)              | One request every 6s. Excess gets 503 immediately. Strict to the point of breaking double-clicks.     |\n| `limit_req zone=z burst=5;` (no nodelay)    | First 5 from a flurry queue up and *trickle out* at 1 every 6s. Legitimate users see a long delay.    |\n| `limit_req zone=z burst=5 nodelay;` (ours)  | First 5 from a flurry are served **immediately**. After that, rate kicks in (excess → 503/429).       |\n\nIn plain words: **per-IP, sustained max 10 attempts/min, with a 5-request burst served instantly.** That covers human double-clicks and router check-in retries comfortably, while capping a brute-force script to ~10/min — slow enough that bcrypt's per-attempt cost makes any reasonable password infeasible to crack online.\n\nRule of thumb when tuning: `rate` is for the attacker's average; `burst + nodelay` is for the legitimate user's UX.\n\n## Env vars\n\n| Var               | Default         | Notes                                |\n| ----------------- | --------------- | ------------------------------------ |\n| `PORT`            | `8350`          | Port to listen on                    |\n| `HOST`            | `0.0.0.0`       | Interface to bind                    |\n| `USERS_FILE`      | `./users.json`  | Path to bcrypt-hash map              |\n| `FIXED_TIMEOUT`   | (unset)         | Hard cap from login. `Nd|Nh|Nm|Ns`.  |\n| `SLIDING_TIMEOUT` | (unset)         | Inactivity timeout. Same format.     |\n| `SWEEP_INTERVAL`  | `24h`           | How often to evict expired entries from the in-memory map. Cleanup-only; doesn't affect when an IP loses access. |\n| `TRUST_REMOTE_ADDR` | `no`          | **Dev only.** When `yes`, falls back to `req.socket.remoteAddress` if `X-Forwarded-For` is missing. Lets you test the form via `http://localhost:8350` without an Nginx in front. Leave unset in production. |\n| `DEBUG`           | `no`            | `yes` to log every request           |\n\nAt least one of `FIXED_TIMEOUT` and `SLIDING_TIMEOUT` must be set; setting both means an entry expires at whichever fires first. Recommended starting point: `FIXED_TIMEOUT=8h SLIDING_TIMEOUT=30m`.\n\n## Tests\n\n```sh\nnpm test            # unit tests (Jest)\nnpm run smoke       # end-to-end: spins up the server with a temp users file, hits every endpoint, reports\n```\n\n## Docker\n\nThe shipped `docker-compose.yaml` pulls the CI-built image from GitHub Container Registry and runs it as a container that publishes port 8350 to **localhost only** (not exposed on your LAN). Your Nginx (running on the host) reaches it at `http://127.0.0.1:8350`.\n\n```sh\n# Create users.json (see Setup above), then:\ndocker compose up -d\n\n# Verify the container is up and healthy\ndocker compose ps\n```\n\nTo build locally from the Dockerfile instead of pulling, see the comments in `docker-compose.yaml`.\n\nIn your Nginx config, the `proxy_pass` lines all point at `http://127.0.0.1:8350/...`.\n\n**Why bind to localhost only.** The gate has no business being directly reachable from outside the host — it should only ever be hit via your reverse proxy. Binding to `127.0.0.1:8350` keeps it that way without you having to do anything. If you ever need to run Nginx on a different machine from the gate, change the bind to `0.0.0.0:8350` and put TLS + auth (or a private network) in front.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fcodeniko%2Fnginx-ip-gate","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fcodeniko%2Fnginx-ip-gate","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fcodeniko%2Fnginx-ip-gate/lists"}