Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

portcullis

Put single sign-on in front of services that don't have any — without modifying them. Point a hostname at portcullis, and it verifies a signed session cookie before anything reaches the backend.

One dependency, ~450 lines, 53 tests.

                    ┌──────────────┐
  browser ────────► │  portcullis  │ ──► prometheus (no auth of its own)
  (session cookie)  │              │ ──► grafana    (identity forwarded)
                    │  verify or   │ ──► jupyter    (IP-trusting backend)
                    │  bounce      │
                    └──────────────┘
                           │
                           └─ no cookie ──► your login service

What this is not

portcullis does not issue sessions. It verifies HMAC-signed JWT cookies that something else minted. You need a login service that authenticates people and sets that cookie; this is the enforcement half only.

If you want a full identity provider — login UI, user database, OIDC, 2FA — use one. Authelia and Authentik exist and do far more than this does. portcullis is for the case where you already have a way to sign people in and you just need something small and auditable standing in front of twelve services that each have their own bad opinion about authentication.

Quick start

node bin/portcullis.js secret > jwt-secret

Write a config:

{
  "port": 8080,
  "loginUrl": "https://login.example.com",
  "jwt": { "secretFile": "./jwt-secret", "issuer": "login.example.com" },
  "routes": {
    "metrics.example.com":    "http://prometheus:9090",
    "dashboards.example.com": { "target": "http://grafana:3000", "forwardUser": true },
    "status.example.com":     { "target": "http://statuspage:3000", "public": true }
  }
}
node bin/portcullis.js serve portcullis.json
portcullis on 0.0.0.0:8080 — 3 routes (1 public)

Your login service issues a cookie whose payload is {"sub":"ada","iss":"login.example.com","exp":1790000000}, signed HS256 with the same secret. That's the entire contract.

Route options

Option Default What it does
target Backend URL. Required. A bare string is shorthand for { "target": ... }.
public false Skip auth entirely. For apps that authenticate at their own edge.
allowScopes [] Scopes permitted here. Empty means unscoped identities only.
forwardUser false Send the verified identity as X-Forwarded-User.
stripClientIp false Remove inbound client-IP headers. See below.
insecureUpstream false Don't verify the backend's TLS certificate (self-signed admin UIs).
websocket true Allow protocol upgrades on this route.

The four things this gets right

Most of the value here is in four behaviours that are easy to miss and quiet when wrong.

1. The identity header is deleted before it is set

If you forward X-Forwarded-User to a backend, that backend trusts it. A client can send that header too. If the proxy only sets it on routes configured to forward identity, then on every other route the client's own value passes straight through to a backend that believes it.

delete req.headers[cfg.identityHeader];        // unconditional, always first
if (route.forwardUser && claims) { ...set it... }

One line, and it has to be unconditional. There's a test for the case that actually depends on it — a forwarding route with no claims, where nothing overwrites the client's value.

2. Any scoped identity is denied unless the route names its scope

Not a deny-list of known-bad scopes. A default deny on having a scope:

if (claims.scope && !route.allowScopes.includes(claims.scope)) return deny;

The difference matters in a year. When you add a new tier to your login service — a trial user, a share link, a read-only guest — it arrives here already denied. Written the other way, that new tier silently inherits access to every backend the day it ships and nothing tells you.

3. WebSocket upgrades get the same authorization as requests

The routine miss. The HTTP path gets locked down, upgrade is left open, and anything with a socket-based API — a console, a live log stream, a socket.io admin channel — stays reachable unauthenticated. Same identity resolution, same decide() call, and no redirect is possible mid-upgrade so the only honest answer is to hang up.

4. Refusal is 403, not a redirect

A caller who has a valid identity that isn't allowed here must not be sent to log in. They'd authenticate successfully, come back, and be refused again, forever. Missing identity is a 302; wrong identity is a 403.

And one that only bites in the real world

Backends that authenticate by source IP — a "trusted networks" provider, an allow-listed subnet — break behind a proxy. Your edge already inserted X-Forwarded-For, so the backend evaluates its rules against an untrusted internet address and demands its own login on top of yours.

"stripClientIp": true removes the inbound client-IP headers so the backend falls back to the socket address — portcullis — which it trusts. Turning off xfwd alone is not enough: that only stops you appending, it doesn't remove what the edge already set.

Bearer tokens

For native apps and scripts that can't hold a cookie.

node bin/portcullis.js mint app-tokens.json --sub phone@example.com --hosts dashboards.example.com --days 90
portcullis: shown once, not recoverable:
pat_rfemZFpLLmVES85HV9LvQcugfCylCnCi
node bin/portcullis.js list app-tokens.json
active   phone@example.com    dashboards.example.com

Three properties, all worth stating:

  • The store holds only sha256 hashes. A leaked store yields no usable credentials.
  • Every token carries a host allow-list. A stolen phone token cannot reach your hypervisor. Omitting --hosts warns you.
  • It fails closed. Missing, unreadable, or malformed store means zero tokens — never an open door.

Mount the store read-only into the proxy. It only ever reads, and a compromised proxy that cannot mint tokens is a much smaller problem. revoke takes effect on the next request; the store is re-read when its mtime changes, so no restart.

Config errors are fatal

Deliberately asymmetric with the token store. A bad token store fails closed and silently, because it must not take the proxy down. Bad config refuses to boot, because a proxy that silently drops a route or an auth requirement is worse than one that doesn't start.

portcullis: jwt secret is shorter than 32 bytes — generate one with `openssl rand -hex 32`

Tests

npm test

53 tests, no framework — node:test and node:assert.

The suite is mutation-checked: deleting the unconditional identity-header strip fails 5 tests, and removing the scope default-deny fails 5 others. That check earned its keep — it caught a test of mine that asserted the right thing but passed either way, because on a forwarding route the assignment masked the missing delete.

Limitations

  • Verifies sessions, does not issue them. You need a login service.
  • HS256 only, by design — alg is never read from the token, which removes algorithm-confusion attacks by construction. No RS256, no JWKS, no key rotation endpoint.
  • Routing is by hostname only. No path-based rules.
  • No rate limiting, no brute-force protection, no audit log. Those belong at your edge or in your login service.
  • Single process, no clustering. It's a proxy in front of a homelab, not a CDN.

License

MIT — see LICENSE. Copyright (c) 2026 Amur Labs LLC.

About

Forward-auth reverse proxy: put single sign-on in front of services that have no auth of their own, without modifying them. One dependency, 53 tests.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages