You have a local AI tool — Open WebUI, Grafana, an internal dashboard, a chat backend — that you want to expose on the public internet via a Cloudflare Tunnel (or Tailscale Funnel, or a reverse-proxy). But you don't want to wire SSO yet, you don't want HTTP basic auth showing up in browser bars, and you don't want strangers hitting it.
pingate is one small FastAPI service that sits in front, asks for a PIN,
verifies it via HMAC-SHA256 signed cookies, and reverse-proxies authenticated
traffic to your upstream. Bonus: optional same-origin LiteLLM proxy (so your
LLM master key never ships in a browser bundle), optional CSS theme injection,
WebSocket passthrough.
public internet
│
▼
┌──────────────┐ cookie ok? ┌─────────────────────┐
│ pingate │ ───── yes ─────▶ │ your upstream │
│ :3060 │ │ (OWUI / Grafana / │
│ │ ───── no ──────▶ │ anything HTTP) │
│ PIN form + │ /pin form └─────────────────────┘
│ signed cookie│
└──────────────┘
│
│ (optional)
▼
┌──────────────────────┐
│ /api/llm/* same- │ ──── cookie ok + Origin ok ────▶
│ origin LiteLLM │ inject Bearer ${LITELLM_KEY}
│ proxy mode │ → LiteLLM proxy
└──────────────────────┘ (key never leaves server)
docker run --rm -d \
--name pingate \
-p 3060:3060 \
-e PINGATE_PIN=0000 \
-e PINGATE_SECRET="$(openssl rand -hex 32)" \
-e PINGATE_UPSTREAM=http://your-upstream:3050 \
ghcr.io/karany97/pingate:latestgit clone https://github.com/karany97/pingate.git
cd pingate
pip install -r requirements.txt
PINGATE_PIN=0000 \
PINGATE_SECRET="$(openssl rand -hex 32)" \
PINGATE_UPSTREAM=http://localhost:3050 \
PINGATE_PORT=3060 \
python app.py# Open in browser → PIN form appears
open http://localhost:3060/
# Enter PIN → cookie set → upstream rendered
# Refresh in next 30 days → upstream rendered (cookie persists)- GET
/pin→ 1-field PIN form (vanilla HTML, no JS frameworks) - POST
/pin/checkwithpin=0000 - If PIN matches
PINGATE_PIN, setSet-Cookie: pingate=<value>.<sig>wherevalueis<unix_timestamp_30d_from_now>andsigishmac_sha256(PINGATE_SECRET, value).hexdigest() - Redirect to
?next=(defaults to/) - Every subsequent request: verify the signature, check the timestamp
hasn't expired, reverse-proxy to
PINGATE_UPSTREAMwith optionalX-Trusted-User-Email: ${PINGATE_TRUSTED_EMAIL}header
The cookie is SameSite=Lax, Secure (when behind HTTPS), HttpOnly. 30-day
expiry default (rotate PINGATE_SECRET to invalidate all outstanding
cookies in one shot).
Cloudflare Access does this for $7/user/month + you have to give Cloudflare
your auth provider. Caddy's basicauth directive does this without sessions
(every request re-auths). Authelia / Authentik are overkill for "I just want
a PIN in front of OWUI." pingate is the missing 500 LOC between "expose
it raw" and "stand up a real IdP."
We've shipped this pattern in production for a year. It's solid. Now it's MIT.
If you set LITELLM_MASTER_KEY in the environment, pingate mounts an extra
route /api/llm/{path:path} that:
- Validates the gate cookie (same as the main proxy)
- Validates the request's
OriginmatchesLITELLM_ALLOWED_ORIGIN - Strips any
Authorizationheader the client sent - Injects
Authorization: Bearer ${LITELLM_MASTER_KEY} - Forwards to
LITELLM_INTERNAL(defaults tohttp://127.0.0.1:8008) - Streams SSE for
chat/completions, handles CORS preflight
Your browser-side chat bundle calls fetch('/api/llm/v1/chat/completions', { credentials: 'include' }) with NO Bearer key. The key never appears in any
JS your users can DevTools-inspect. Critical for any chat frontend you want
to make fully public.
If you set PINGATE_THEME_CSS_PATH=/path/to/your.css, pingate injects the
CSS into every HTML response from the upstream (just before </head>). This
is the simplest path to re-theme an open-source tool without forking it.
WebSocket connections pass through untouched (no CSS injection on streams).
Set PINGATE_TRUSTED_EMAIL and (optionally) PINGATE_TRUSTED_NAME —
pingate adds them as X-Trusted-User-Email / X-Trusted-User-Name headers
on every proxied request. Works directly with
Open WebUI's WEBUI_AUTH_TRUSTED_EMAIL_HEADER
auto-login, Grafana's auth.proxy, and anything else that does
header-based SSO.
Per-request WebSocket upgrades transparently. The cookie is checked once on the upgrade handshake; subsequent frames flow.
| Env var | Default | What it does |
|---|---|---|
PINGATE_PIN |
0000 (CHANGE IT — sample only) |
The PIN users enter |
PINGATE_SECRET |
dev placeholder — CHANGE IT | HMAC-SHA256 key for signing cookies |
PINGATE_UPSTREAM |
http://127.0.0.1:3050 |
What pingate reverse-proxies to |
PINGATE_PORT |
3060 |
pingate's listen port |
PINGATE_COOKIE_NAME |
pingate |
Cookie name (lets multiple gates coexist) |
PINGATE_TRUSTED_EMAIL |
(empty) | Optional X-Trusted-User-Email value |
PINGATE_TRUSTED_NAME |
(empty) | Optional X-Trusted-User-Name value |
PINGATE_EXTRA_HEADERS |
{} |
JSON dict of extra headers (e.g. {"X-Webauth-User":"admin"} for Grafana) |
PINGATE_THEME_CSS_PATH |
(empty) | Path to a CSS file to inject into HTML responses |
LITELLM_MASTER_KEY |
(empty) | Enables /api/llm/* same-origin proxy when set |
LITELLM_INTERNAL |
http://127.0.0.1:8008 |
LiteLLM endpoint to inject the key into |
LITELLM_PROXY_PATH |
/api/llm |
Mount point for the LiteLLM proxy |
LITELLM_ALLOWED_ORIGIN |
(empty) | Origin allowlist for CORS preflight |
| Surface | Status |
|---|---|
| HTTP reverse-proxy (GET/POST/PUT/PATCH/DELETE/HEAD/OPTIONS) | ✅ |
| WebSocket reverse-proxy (Socket.IO friendly) | ✅ |
| Same-origin LiteLLM proxy with key injection | ✅ |
| CORS preflight with Origin allowlist | ✅ |
| Streaming SSE pass-through | ✅ |
| Theme CSS injection | ✅ |
| Trusted-header SSO | ✅ |
| Multi-instance (different cookie names) | ✅ |
- Multi-user with per-user PINs. This is "one PIN for everyone with access." For multi-user, point pingate at a real IdP (Authelia, Authentik, Keycloak).
- OAuth / OIDC. Same reason.
- Rate-limiting. Put Caddy/nginx in front.
| You're using | Plug pingate in like |
|---|---|
| Open WebUI | cloudflared → pingate → OWUI. Set PINGATE_TRUSTED_EMAIL so OWUI auto-logs in. |
| Grafana | cloudflared → pingate → Grafana. Set PINGATE_EXTRA_HEADERS='{"X-Webauth-User":"admin"}'. |
| Destiny Atelier | The auth-proxy pattern Destiny Atelier ships is the same pattern as pingate's LITELLM_MASTER_KEY mode. |
| tooltalk + moa-router | Stack: pingate → moa-router → tooltalk → Gemma 4. PIN at the edge, voting in the middle, tool-call shape at the inside. |
MIT. Fork it, sell it, ship it.
Pattern extracted from the Destiny Atelier chat stack — the
same gate that's been protecting atelier.example.com for a year, generalized
into its own MIT repo because the same problem keeps coming up: "I want my
local AI thing on the internet but I don't want a real IdP yet."