Checking Cloudflare Access twice

Most of the admin tools behind my home server can be reached from outside the house only through Cloudflare Access. A request arrives at Cloudflare’s edge, the edge asks who you are, and only a signed-in request goes down the tunnel to the house. That edge check is the part everyone sets up. This post is about the second check, the one at my end of the tunnel, and why I don’t let the first one stand alone.

What the edge check rests on

The homelab case study describes the path: nothing at home listens to the internet, and the only way in is a tunnel that the server opens outbound to Cloudflare. In front of each admin hostname sits an Access application with a policy: sign in with one of the offered methods, and you may pass.

On its own, that check rests on one assumption: everything that reaches the tunnel came through the policy. It usually has. But a policy can be edited by mistake, an application can be deleted, and a hostname can be added to the tunnel without one. From the origin’s side those cases all look the same as a good day, because every request arriving through the tunnel comes from Cloudflare. The house has no way to tell whether the edge actually asked anyone to sign in.

The connector checks again

The tunnel connector can be told, per hostname, which Access application it serves and that a token is required. With that set, the connector validates the Access token itself, at the origin, before it forwards anything. A request that somehow reached the tunnel without passing the edge policy is still rejected, by my own side.

So for the admin tools on cs, Access is enforced twice: once at the edge, and once more by the connector, which checks the token for that one application before it forwards. The ingress map that holds those settings is a file in git, and the comment beside each entry says why it is there, so nobody tidies the blocks away while editing a route.

Where that isn’t possible

Not every client can carry a token. File-sync clients and the public pages of a form service talk to one of my services without ever seeing a login page, and if the connector demanded a token from them they would get a refusal at the origin. So that service is checked at the edge only, where separate rules cover the paths those clients use, and its entry in the ingress file carries a comment saying exactly that.

That is the honest limit of the design: not every hostname on the tunnel gets the second check, and where one deliberately doesn’t, the file says why.

A check that runs on every push

The ingress file is the source of truth for the tunnel, and the deploy script ships whatever is on the main branch. So a file nobody parsed can be merged and deployed. To close that, a small script asserts a handful of structural rules over the file: it parses with a non-empty ingress list, the catch-all rule is last and every other rule names a hostname, every HTTPS rule names its origin server, hostnames are unique, and every entry that requires an Access token names its application. Continuous integration runs it on every push, against the live file and against deliberately broken copies that must fail. The script needs no credentials and reaches nothing, so it is not a substitute for the dry run that happens before the file is pushed to Cloudflare. It catches the earlier failure, where the file reaches the main branch in a state nobody checked.

Inside the house, the request skips sign-in

The reverse proxy on cs has a second entrypoint that is deliberately not published, so it exists only on the internal network and only the tunnel connector can reach it. Routes on that entrypoint skip the sign-in middleware, because Access has already checked the request twice. The same hostnames on the LAN go through the household sign-in as before.

The trade-off is duplication: each of those services has two routes, one per entrypoint. The alternative was one route with sign-in on both paths, which would have meant two logins for every remote visit, the Access one and then the household one. I took the duplicate route and a comment on each pair saying why it has no sign-in.

A Worker that verifies the token itself

One small Cloudflare Worker serves a page that hands a signed-in family member one setup detail. An Access application stands in front of its custom domain, and the Worker verifies the Access token itself as well: the signature against the team’s published certificates, the audience equal to this application’s, the team as issuer, the expiry, and the not-before time with a small allowance for clock skew. Anything else gets a 403. If the certificates can’t be fetched, the answer is a 503 rather than a guess, so it fails closed. The addresses the platform would give the Worker by default are switched off, so the custom domain is the only way to reach it.

The audience check is the one that matters, and the code says why: without it, a valid token from any other Access application in the account would pass. That is the same lesson as the connector’s check, one layer down. “Signed by Cloudflare” is not the same as “signed in to this”.

What the sign-in offers

The login methods on those Access applications are Google, Authelia, and since 10 October 2026 authentik, which the household is moving to in phases. The sign-in services themselves are watched from outside the house, and their rows are on the public status page.

Update, 23 October 2026: since 14 October 2026, authentik is the only login method on those applications besides Google; Authelia is no longer offered.

What I’d still change

Two things are open. The edge-only exceptions are ones I’d rather not have, and they stay until that service’s clients can carry a token. And the Worker depends on Cloudflare’s certificate endpoint at request time, so an outage there turns into a 503 for the family rather than a page. Both are recorded next to the configuration, which is where I’d want the next person to find them.

← All writing