Caputchin
Proxy page-gate

Reverse-proxy recipes and the callback contract

View as Markdown

Every reverse proxy needs the same two pieces: an authorizer check that asks Caputchin whether a request carries a valid pass, and a callback on your origin that turns a solved pass into the cpt_gate cookie. The nginx walkthrough shows the shape end to end; this page gives the other proxies, the exact callback contract, and a full Authelia example. Replace YOUR_SITE_KEY with your public key throughout.

The endpoints are the same everywhere:

  • Authorizer — GET https://verify.caputchin.com/v1/gate/authz?site=YOUR_SITE_KEY, with the visitor's Cookie header forwarded. 204 means allow, 401 means challenge.
  • Challenge — redirect the browser to https://verify.caputchin.com/v1/gate/challenge?site=YOUR_SITE_KEY&return=<the original URL>.

The authorizer answers only 204 or 401. Anything else, including a timeout, means Caputchin is unreachable, and what happens then is your fail mode: block the request (fail closed, safer for a login portal) or let it through (fail open). Set it on the site key and the generated snippet matches. Give the authorizer subrequest a short timeout so an outage cannot stall every request while it decides.

Traefik

Use a forwardAuth middleware for the authorizer. Traefik forwards the incoming Cookie header by default. On a 401 from the middleware, redirect the browser to the challenge (an error-page middleware pointed at a small route that issues the 302, or handle the 401 at your edge).

http:
  middlewares:
    cpt-gate:
      forwardAuth:
        address: "https://verify.caputchin.com/v1/gate/authz?site=YOUR_SITE_KEY"
        # The 401 from forwardAuth is your signal to send the browser to:
        #   https://verify.caputchin.com/v1/gate/challenge?site=YOUR_SITE_KEY&return=<original-url>
  routers:
    portal:
      rule: "Host(`auth.example.com`)"
      middlewares: ["cpt-gate"]
      service: your-app

Caddy

Use forward_auth for the authorizer and handle_errors to turn the 401 into a redirect. This is a starting point; adjust directive syntax for your Caddy version.

auth.example.com {
  forward_auth https://verify.caputchin.com {
    uri /v1/gate/authz?site=YOUR_SITE_KEY
    copy_headers Cookie
  }
  handle_errors {
    @challenge expression {http.error.status_code} == 401
    redir @challenge https://verify.caputchin.com/v1/gate/challenge?site=YOUR_SITE_KEY&return={scheme}://{host}{uri} 302
  }
  reverse_proxy your-app:8080
}

The callback

The hosted check page finishes by auto-submitting a POST form to https://<your-origin>/__cpt/callback with two fields in the body: cpt_gate (the pass) and to (where to send the visitor). If you changed the callback path on the site key, it posts there instead. Add this one route to your origin (or as a dedicated location on the proxy). It must:

  1. Read cpt_gate and to from the POST body, never the URL (a pass in a URL leaks into logs and referrers).
  2. Confirm to is on your own origin, and reject anything else, so the callback cannot be turned into an open redirect.
  3. Set the cookie with the attributes below.
  4. Redirect (303) to to.
// Any tiny handler on your origin works. Express shown; ~15 lines.
app.post("/__cpt/callback", express.urlencoded({ extended: false }), (req, res) => {
  const pass = String(req.body.cpt_gate || "");
  const to = String(req.body.to || "/");
  const target = new URL(to, `https://${req.headers.host}`);
  if (target.host !== req.headers.host) return res.status(400).end(); // same-origin only
  res.setHeader(
    "Set-Cookie",
    `cpt_gate=${pass}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=1800`
  );
  res.redirect(303, target.pathname + target.search);
});

The cookie attributes are the whole security model. Because the pass is not tied to a device, HttpOnly (blocks script theft), Secure (HTTPS only), SameSite=Lax, and a short Max-Age (match your Clearance TTL) are what protect it. Also: do not log the cpt_gate value in your access logs, and keep Referrer-Policy: no-referrer on the callback so the pass never rides along in a referrer header.

Fail mode per proxy

Caputchin never sees a fail-mode decision, so it never appears in your statistics: your proxy makes it locally when it cannot reach the authorizer.

  • nginx — proxy_intercept_errors on; plus error_page 500 502 503 504 = @cpt_unreachable;, where that named location is return 403 (closed) or return 204 (open).
  • Traefik — the forwardAuth middleware errors out on an unreachable address; route that to a service returning 403 (closed), or drop the middleware from the router behind a healthcheck (open).
  • Caddy — extend handle_errors with a matcher on 5xx that either respond 403 (closed) or respond 204 (open).

Path scoping per proxy

Gate only these paths and Never gate these paths shape the generated configuration; your proxy enforces them.

  • nginx — one location ^~ <prefix> per gated path, and auth_request off; inside each excluded one. An extension pattern like *.php becomes location ~* \.php$.
  • Traefik — attach the cpt-gate middleware only to the routers whose rules match the gated paths, and leave it off the rest.
  • Caddy — wrap forward_auth in a path matcher, for example @gated path /admin/* /login then forward_auth @gated ....

Never gate the authorizer subrequest or your callback path.

Worked example: Authelia

Authelia's login portal is a compiled single-page app with no place to embed the widget and no plugin hook, so the gate is the way to protect it. Authelia already sits behind a reverse proxy (that is how its own ForwardAuth works), and that proxy is where the gate goes. Authelia itself is left completely unmodified.

Put the gate on the portal's vhost (for example auth.example.com):

  • Gate the portal HTML and the first-factor login endpoint (/api/firstfactor).
  • Exclude Authelia's own ForwardAuth verify endpoint (/api/verify), which the proxy calls for every protected upstream request. Gating it would break every protected app behind Authelia.
  • Exclude health checks and the static assets the challenge page itself needs.
  • Add the /__cpt/callback route as above (or your configured callback path).

Those four bullets are exactly what the path scope settings express: / and /api/firstfactor as gated paths, /api/verify and your healthcheck as excluded ones.

The gate keeps bots off the portal; Authelia's built-in Regulation (retry limit and ban) still caps credential retries for whoever passes, so the two layers stack. The gate protects the interactive portal (the login and consent UI) only; it does not, and should not, gate non-interactive token endpoints.

See also

On this page