TTerramantle
Get Started Free
Security

Country, ASN and IP: locking a Terraform org to the networks its pipelines run in

A bot token for the state backend ends up in a CI log. Whoever holds it is authenticated, and the state files it can read are where the secrets are. The one thing still telling that request apart from your pipeline's is where it came from. Country says GB. The network says M247.

Terramantle never runs your Terraform, so it never holds your cloud credentials. What it does hold is your state, your modules and the providers your pipelines pull, and the credentials that reach them are bot tokens and CI OIDC tokens. A leaked token is authenticated. Role checks pass. Policy has nothing to say, because reading state is not a policy question. The remaining control is the one most registries do not have: may a request from this network act for this org at all.

That is what per-org network access rules are. One rule set per org, in allow or deny mode, over three lists: countries, autonomous system numbers and IP ranges. It is evaluated in-process at every point where a caller is bound to an org, before any bytes are accepted and without Rego, from facts the Cloudflare edge attaches to the request. This page is about why the middle list, the ASN, is the one worth learning to use.

What the edge knows about a request

Every request to the Worker arrives through Cloudflare, and the edge attaches what it knows about the connecting address before any code of mine runs. Nothing a client sends can set these fields; the edge overwrites them for proxied traffic.

FactFormNotes
IP addressIPv4 or IPv6Matched against CIDR entries; a bare address is one host
CountryISO-3166 alpha-2, upper caseAlso T1 for Tor exit traffic and XX for unclassified, both kept verbatim so a rule can name them
Autonomous systemASN plus the operator's nameWhich network announced the route for that address: an ISP, a cloud, a hosting company. No header carries it; it comes from the edge alone

An unknown fact fails closed where the rule needs it. In allow mode an unknown value does not match anything, so the request is refused. In deny mode an unknown value under a non-empty list is refused too, because the evaluator cannot show it is not listed. The comment in the code puts it as: a fact we could not read cannot be shown to be off the list.

Why IP allow-lists rot

The first instinct is a CIDR allow-list, and for a self-hosted runner behind a fixed egress address it is the right instinct. For hosted CI it is a maintenance job that nobody owns. A GitHub-hosted runner gets a different address every job, drawn from ranges GitHub publishes and republishes as they change. The ranges are Azure's. A list copied from the API is correct on the day it is pasted and quietly wrong some weeks later, and the failure mode is not a refusal you notice but a widening you do not: the admin adds a broader range to make the red pipeline go green, and the allow-list now covers a region of a public cloud.

A rule set may hold up to 256 CIDRs. That cap is there so the list stays reviewable, not because anyone should approach it.

Why country is too coarse

Country is the control everyone reaches for second, and it does one thing well: it refuses traffic from places your organisation has no business in. What it cannot do is tell your pipeline from a stranger standing in the same country. Return to the leaked token. The attacker's first move is a VPN, and every commercial VPN sells exits in your country. A country allow-list of GB admits a GB exit on a London hosting network as readily as it admits your runner. It also admits every coffee shop, every compromised laptop and every Tor exit the edge happened to classify by location rather than as T1.

Country is still useful. It is the right list for humans, where you want "our people, from the places our people are" and nothing finer. It is the wrong list for machines.

Why ASN is the right handle

An autonomous system number names the organisation that operates the network a request came from. That is almost always what an access rule means to say. "Only our pipelines" means "only requests from the network our pipelines run in", and that network has a number. GitHub-hosted runners are in Microsoft's, AS8075. A runner fleet on EC2 is in Amazon's, AS16509. A self-hosted runner is in your ISP's or your colocation provider's. The tooltip on the portal field says the same thing in one sentence: hosted CI runners sit in a small set of networks while their IP ranges churn, so ASN is usually the right handle for "only our pipelines".

{
  "enabled": true,
  "mode": "allow",
  "countries": [],
  "asns": [8075],
  "cidrs": [],
  "applies_to": "machine"
}

That rule set says: bot tokens and CI tokens for this org work from GitHub-hosted runners and from nowhere else. It does not need updating when GitHub republishes its ranges, because the ranges move inside the ASN. It refuses a leaked token used from a laptop, from a VPN exit, from a Tor exit and from a server in someone else's cloud, and it does so without naming any of them.

The same property makes ASN the right handle for the opposite job. Commercial VPN infrastructure is concentrated. The VPNalyzer study found 27 of the 80 providers it tested had servers inside one autonomous system, M247's AS9009, and IPinfo's data puts 83.6 percent of commercial VPN traffic on hosting networks rather than residential or business ones. A deny-list of a few dozen hosting ASNs refuses most VPN brands at once, by network, without maintaining an IP reputation feed, and the edge's own T1 code covers Tor.

The limit, which matters: an ASN is a network, not an identity. Everything in Azure is in AS8075, including an attacker who rented a VM there. The rule narrows the world a stolen token works from; it does not replace the token's own security. The code describes itself as a tenant safeguard, not the authentication boundary, and that is the right way to think about it.

Allow for pipelines, think twice for people

A rule set applies to machine callers by default, meaning bot tokens and CI OIDC tokens. Humans are exempt unless an admin opts them in, so an admin cannot lock the team out by mistake while tightening the pipeline rule. That default follows from the argument above: the strongest posture, an ASN allow-list, is a machine posture. People move networks all day; runners do not.

When humans are opted in, the same rule set governs portal, API and CLI use, and the save is refused with 422 self_lockout if it would deny the admin making it. Here the natural wish is "allow our countries, and deny the VPN networks", and I want to be exact about what exists. A rule set is one mode. In allow mode a request must match at least one listed country, ASN or CIDR; in deny mode it must match none. There is no second layer that applies a deny-list on top of an allow-list, so today you choose which of the two lists matters more for your people. For pipelines the question does not arise, because an ASN allow-list already excludes every network a VPN exit lives in.

What happens when a rule fires

The caller gets 403 access_rule_denied and a message that never names the rule or the origin, so a refused attacker learns nothing about what to change. The Worker log carries the country, ASN, network name and IP for the operator. The refusal is not written to the policy decision ledger, because it is not a policy decision; it happens before policy runs. Saving a rule set is audited with the full before and after, and deleting one is audited when there was something to delete.

Before arming a set, the preview replays the draft against the last seven days of recorded origins, the current lock holders on every workspace and each bot token's last-seen location, through the same evaluator the request path uses, and reports how many it would have refused and from which countries. One honest gap: no recorded origin carries an ASN, so ASN entries contribute nothing to the preview. In allow mode that overstates refusals, a runner your ASN entry would admit shows as denied, and the response says so whenever the draft lists any ASN.

Where the check runs

The rule is evaluated wherever the caller is bound to the org: the role guards, the state permission resolver on reads, locks and pushes, the module download path, and the three points where a provider request is resolved to a tenant. It runs in-process, by primary key, on every applicable request, with no cache, because, as the code puts it, an admin tightening a rule expects the next request to obey it. It runs before any bytes are accepted, so a refused state push is refused before the state is read.

What this does not do

It is not authentication, and a request still needs a valid token and the right role after it passes. An ASN is a network, not an identity, and a cloud ASN admits every tenant of that cloud. The facts are the Cloudflare edge's view of the connecting address, so a corporate egress proxy makes everyone behind it one origin, and geolocation is as accurate as the edge's database. One rule set, one mode: there is no allow-list with a deny-list layered on top. Humans are exempt until opted in. The preview cannot evaluate ASN entries. And if the rule row cannot be read because the database is unavailable, the request proceeds and the failure is logged, because a transient read error must not become an outage for every org with a rule; the design treats this as a safeguard on top of the authentication boundary, not as the boundary itself. For the credential side of the same problem, see CI credential theft.

Access rules FAQ

Which tier has network access rules?

Every tier, including the free Solo tier. The only gate is the org admin role. One rule set per org, saved and audited as a whole.

How do I find the ASN my pipelines run in?

GitHub-hosted runners run in Microsoft Azure's network, AS8075. For anything else, take the IP from a recent lock holder on a workspace, or a bot token's last-seen origin, and look it up in any ASN database. Self-hosted runners are in whatever network you put them in, which is the point.

Can I allow my countries and block VPN networks at the same time?

Not in one rule set today. A rule set is either an allow-list or a deny-list over its three lists. For pipelines, an ASN allow-list gets you the exclusion anyway, because commercial VPN exits are not inside the networks CI runners use. For humans, who are exempt by default, pick the one list that matters more to you. Both at once is the next change to this feature.

What does a refused caller see?

A 403 with the machine code access_rule_denied and a message that never names the rule or the origin. The operator sees the country, ASN, network name and IP in the Worker log. The refusal is not written to the policy ledger, because it is an authorisation decision, not a policy one.

Can I lock myself out?

Not by saving. A rule set that applies to humans and would refuse the request saving it is rejected with 422 self_lockout. Machine-only rules cannot lock a human out at all. Tightening a rule takes effect on the next request; there is no cache in front of it.

Last reviewed

Terramantle is a private Terraform and OpenTofu registry. See pricing or read the FAQ.