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:

  • AuthorizerGET 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>.

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 challenge 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). 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.

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.

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