Philipp Lehmann @PhilippTheServer

Group-Based Default-Deny Instead of Hand-Maintained Peer Lists

Replacing per-peer NetBird rules with groups, default-deny, and a check that proves it

The problem

Ask a straightforward question of most mesh VPNs after a year of organic growth: which peers can reach the database tier. Answering it means opening every peer’s configuration, checking whether its allow-list contains the database’s address, and then checking whether that peer is still the thing you think it is. There is no single place to read the answer.

The rot looks like this, on a peer that was never a database client:

# WireGuard peer config, added during an incident eight months ago.
# TODO: remove once the migration is done.
[Peer]
PublicKey = <redacted>
AllowedIPs = 10.20.0.0/24, 10.20.4.17/32

10.20.4.17 is the database host. The TODO never got actioned, because removing a rule requires proving nothing else depends on it, and nobody wants to be the person who breaks a production path to save a line in a config file. Adding a rule costs one line and no review. Removing one costs an investigation. Given that asymmetry, allow-lists only grow.

Two things make this specific to VPNs and worse than it looks in a firewall audit:

  • Addresses are not identity. A peer that gets re-provisioned, a laptop that re-enrols, a VM that gets replaced by infrastructure-as-code — all of these can end up with a new address. A rule written against an address survives the peer it was written for and, eventually, starts matching whatever is issued that address next.
  • The failure is silent. A stale allow-list entry does not error. It sits there working correctly for the peer that no longer needs it, and starts working incorrectly the day the address it names is reused. Nothing signals that moment.

The question “who can reach the database tier” should be answerable by reading a policy, not by reconstructing an access-control list from a directory of peer configs that nobody maintains as a set.

Working through it

The address was never the right thing to write into a rule

NetBird already separates a peer’s identity (a stable object in its inventory) from its address (assigned from a CGNAT range, 100.64.0.0/10, when the peer connects). That separation is wasted the moment a policy is written against the address instead of the identity. NetBird’s access-control model gives you the identity-shaped primitive to write rules against instead: the group.

Groups are computed membership, not a list you type

A NetBird group is a label that can be attached to a peer or to a user, and a peer can belong to any number of groups at once. The important property is how peers end up in a group — none of the three ways involves a human enumerating peers by address:

  • Setup keys. A setup key carries an auto_groups field. Every peer that enrols with that key is placed in those groups automatically, at enrolment. This is how servers and other headless infrastructure get grouped: the group is a property of which key provisioned the machine, decided once, in code.
  • SSO / identity-provider propagation. When a user authenticates via SSO — Keycloak, in a stack that already runs Keycloak for everything else — their device inherits the groups mapped to their identity. Revoke the person’s group membership at the identity provider and their devices lose the corresponding NetBird group without anyone touching a NetBird policy.
  • Manual assignment, for the rare peer that genuinely needs a one-off exception. Treat this the way you’d treat a manually-added firewall rule with no ticket behind it: a thing to notice and be suspicious of, not the normal path.

A policy that references role:db-client never has to be edited when a client is added, retired, or re-provisioned. The group’s membership changes; the policy does not.

Turning the default policy off, in the right order

A fresh NetBird account ships with a policy literally named Default that allows every peer to reach every other peer on every protocol. It exists so a new account is usable immediately, not because it is a reasonable end state. Deleting it without a replacement in place means every peer immediately loses reachability to every other peer, including the connection you are managing NetBird over — the same class of self-inflicted outage as restarting sshd before opening the new firewall port.

The safe order is: write and enable the group-scoped policies you actually want, confirm with a real peer that they grant what you expect, and only then delete Default. Confirm before you cut, never after.

A naming scheme that makes a policy readable as a sentence

Flat, one-off group names (db-servers, backend-team, prod2) don’t compose and don’t sort. A prefix:value scheme does both:

  • tier:database — what a peer is.
  • role:db-client — what a peer is permitted to be a source of.
  • env:prod — which environment a peer belongs to.

Read next to a policy named “db-client to database”, role:db-client → tier:database on 5432/tcp answers the opening question directly, without anyone reconstructing anything.

One subtlety worth knowing before you rely on it: a rule’s sources (and destinations) field takes a list of group IDs, and a peer matches if it is in any of them — it is a union, not an intersection. Listing both role:db-client and env:prod as sources does not mean “db-clients that are also in prod”; it means “db-clients, plus every other prod peer regardless of role.” If you need the intersection of two dimensions, the group that represents it has to exist on its own — commonly by running separate NetBird accounts or networks per environment, so a staging peer can never appear in the same policy universe as a prod one, rather than trying to intersect environment and role inside one rule.

Writing a rule that says only what it means

A policy rule has an action (only accept exists — NetBird is allow-list only, so there is no ordering or priority to reason about, and nothing not explicitly allowed is reachable), a protocol, a port or port range, a list of source groups, a list of destination groups, and a bidirectional flag. Leave bidirectional false unless the destination genuinely needs to initiate back to the source on the same protocol and port — a client that only ever calls out to a database does not need it, and setting it anyway quietly doubles the rule’s meaning.

Testing that the policy set only allows what it claims

A group-based policy set can still drift: a peer lands in role:db-client by mistake, a rule gets created bidirectional when it shouldn’t be, a manual per-peer group survives as a backdoor. The fix is the same one you’d apply to any other piece of infrastructure — declare the intended state as data, then check the live state against it.

Concretely: write the access matrix you intend (source group → destination group, protocol, ports) as a small YAML file, fetch the actual groups and policies from the NetBird API, expand every enabled accept rule into concrete (source, destination, protocol, port) pairs, and diff the two sets. Anything live and not declared is undeclared access; anything declared and not live is a policy that stopped doing its job.

The solution

Three files. The first provisions groups, setup keys and the policy via the NetBird REST API (works against NetBird Cloud or a self-hosted management server — set NETBIRD_API accordingly and export a personal access token as NETBIRD_TOKEN). The second declares the intended access matrix. The third checks the live policy set against it.

#!/usr/bin/env bash
# bootstrap-netbird-policy.sh
# Requires: curl, jq. NETBIRD_TOKEN must be a NetBird personal access token.
set -euo pipefail

NETBIRD_API="${NETBIRD_API:-https://api.netbird.io}"
NETBIRD_TOKEN="${NETBIRD_TOKEN:?Set NETBIRD_TOKEN to a NetBird personal access token}"

api() {
  local method="$1" path="$2" body="${3:-}"
  curl -sS -X "$method" "${NETBIRD_API}${path}" \
    -H "Authorization: Token ${NETBIRD_TOKEN}" \
    -H "Content-Type: application/json" \
    ${body:+-d "$body"}
}

create_group() {
  local name="$1" existing
  existing=$(api GET "/api/groups" | jq -r --arg n "$name" '.[] | select(.name==$n) | .id')
  if [ -n "$existing" ]; then echo "$existing"; return; fi
  api POST "/api/groups" "$(jq -n --arg n "$name" '{name: $n, peers: []}')" | jq -r '.id'
}

echo "Creating groups..." >&2
GROUP_DB=$(create_group "tier:database")
GROUP_DBCLIENT=$(create_group "role:db-client")
GROUP_PROD=$(create_group "env:prod")
echo "tier:database  = $GROUP_DB"       >&2
echo "role:db-client = $GROUP_DBCLIENT" >&2
echo "env:prod       = $GROUP_PROD"     >&2

echo "Creating a reusable setup key that enrols database peers into tier:database and env:prod..." >&2
api POST "/api/setup-keys" "$(jq -n \
  --arg name "db-server-enrollment" --arg g1 "$GROUP_DB" --arg g2 "$GROUP_PROD" \
  '{name: $name, type: "reusable", expires_in: 2592000, usage_limit: 0,
    auto_groups: [$g1, $g2], ephemeral: false}')" \
  | jq -r '"  setup key: " + .key'

echo "Creating a reusable setup key that enrols client peers into role:db-client and env:prod..." >&2
api POST "/api/setup-keys" "$(jq -n \
  --arg name "db-client-enrollment" --arg g1 "$GROUP_DBCLIENT" --arg g2 "$GROUP_PROD" \
  '{name: $name, type: "reusable", expires_in: 2592000, usage_limit: 0,
    auto_groups: [$g1, $g2], ephemeral: false}')" \
  | jq -r '"  setup key: " + .key'

echo "Creating the policy: role:db-client -> tier:database on 5432/tcp..." >&2
api POST "/api/policies" "$(jq -n \
  --arg src "$GROUP_DBCLIENT" --arg dst "$GROUP_DB" \
  '{name: "db-client to database",
    description: "Only role:db-client peers may reach tier:database on 5432/tcp",
    enabled: true,
    rules: [{
      name: "postgres",
      enabled: true,
      action: "accept",
      protocol: "tcp",
      ports: ["5432"],
      bidirectional: false,
      sources: [$src],
      destinations: [$dst]
    }]
  }')" | jq -r '"  policy id: " + .id'

cat <<'EOF' >&2

Next: enrol a real peer with each setup key above and confirm reachability matches
expectations (see verify-netbird-policy.py). Only after that, disable or delete the
account's "Default" policy — deleting it first blocks all peer traffic, including your
own connection to NetBird.
EOF
# expected-access.yaml
# The access matrix this policy set is supposed to enforce.
# Anything reachable in the live account that is not listed here has drifted.
rules:
  - source: "role:db-client"
    destination: "tier:database"
    protocol: "tcp"
    ports: ["5432"]
#!/usr/bin/env python3
# verify-netbird-policy.py
# pip install requests pyyaml
"""Diff NetBird's live, group-scoped access rules against a declared expectation."""
import os
import sys

import requests
import yaml

API = os.environ.get("NETBIRD_API", "https://api.netbird.io")
TOKEN = os.environ["NETBIRD_TOKEN"]
HEADERS = {"Authorization": f"Token {TOKEN}"}


def get(path):
    resp = requests.get(f"{API}{path}", headers=HEADERS, timeout=15)
    resp.raise_for_status()
    return resp.json()


def load_expected(path):
    with open(path, encoding="utf-8") as fh:
        doc = yaml.safe_load(fh)
    expected = set()
    for rule in doc["rules"]:
        for port in rule["ports"]:
            expected.add((rule["source"], rule["destination"], rule["protocol"], str(port)))
    return expected


def live_access_pairs():
    groups_by_id = {g["id"]: g["name"] for g in get("/api/groups")}
    pairs = set()
    unresolved = []
    for policy in get("/api/policies"):
        if not policy.get("enabled"):
            continue
        for rule in policy.get("rules", []):
            if not rule.get("enabled") or rule.get("action") != "accept":
                continue
            sources = rule.get("sources") or []
            destinations = rule.get("destinations") or []
            ports = rule.get("ports") or ["*"]
            for src_id in sources:
                for dst_id in destinations:
                    src = groups_by_id.get(src_id)
                    dst = groups_by_id.get(dst_id)
                    if src is None or dst is None:
                        unresolved.append((policy["name"], src_id, dst_id))
                        continue
                    for port in ports:
                        pairs.add((src, dst, rule["protocol"], str(port)))
                        if rule.get("bidirectional"):
                            pairs.add((dst, src, rule["protocol"], str(port)))
    return pairs, unresolved


def main():
    expected = load_expected("expected-access.yaml")
    actual, unresolved = live_access_pairs()

    extra = actual - expected
    missing = expected - actual

    if unresolved:
        print("WARNING: rules reference a group this token cannot resolve "
              "(built-in 'All' group, or a group outside this account's visibility):")
        for policy_name, src_id, dst_id in unresolved:
            print(f"  policy {policy_name!r}: {src_id} -> {dst_id}")

    if not extra and not missing and not unresolved:
        print(f"OK: live policy set matches expected-access.yaml exactly "
              f"({len(expected)} allowed path(s)).")
        return 0

    if extra:
        print("UNDECLARED access (live but not in expected-access.yaml):")
        for src, dst, proto, port in sorted(extra):
            print(f"  {src} -> {dst}  {proto}/{port}")
    if missing:
        print("MISSING access (declared but not enforced):")
        for src, dst, proto, port in sorted(missing):
            print(f"  {src} -> {dst}  {proto}/{port}")
    return 1


if __name__ == "__main__":
    sys.exit(main())

Running NETBIRD_TOKEN=... python3 verify-netbird-policy.py against the account the bootstrap script configured, with the Default policy already removed, prints:

OK: live policy set matches expected-access.yaml exactly (1 allowed path(s)).

Add a peer to role:db-client by hand, or leave Default in place, and the same run prints the extra path instead of exiting clean — which is the point: the check fails the moment reality says more than the policy file claims.

Conclusion

Three things here are not specific to NetBird:

Write rules against identity, not against the address that identity currently holds. Kubernetes NetworkPolicy selectors, AWS security groups referencing other security groups instead of CIDRs, and NetBird groups are the same idea applied to three different layers — the address is going to change, so it cannot be the thing a rule remembers.

Turning on default-deny is a cutover, not a toggle. It has exactly one safe order: prove the replacement rules grant what you need, then remove the blanket allow — never the reverse, because the reverse means finding out what broke by losing the ability to reach the thing you’d use to fix it.

A policy set is only as trustworthy as the check that runs against it. “I read the rules and they look right” degrades every time someone else edits them. A declared expectation and a script that diffs live state against it turns that judgement call into something CI can enforce.

A naming taxonomy is a policy artefact, not decoration. role:db-client is self-documenting only as long as everyone who creates a group uses the same scheme — that discipline is worth enforcing in the same review that would catch a bad Terraform diff.