Cloud Networking

Security Groups: The Stateful Firewall

Source → protocol/port → destination, evaluated per resource, allow-only, and stateful — the reply to an allowed request is always permitted. The single most useful property is that a rule can name another group instead of an address range.

▶ Run the lab

The question this answers

Infrastructure question

What is allowed to reach this specific resource, and what is it allowed to reach in return?

Application requirement

The load balancer must accept 443 from the internet. The application must accept traffic from the load balancer and nothing else. The database must accept 5432 from the application and nothing else — including from other workloads in the same subnet.

What it provides

A per-resource, default-deny inbound boundary whose rules can reference identity-like groups rather than addresses, so it stays correct as instances are replaced and addresses change.

Application RequirementInfrastructure RequirementComputeNetworkStorageIdentityDeploymentScalingReliabilityObservabilitySecurityCostTrade-offs

Allow-only, per resource, and stateful

A security group is a set of allow rules attached to a resource's network interface — not to a subnet, not to a route. There is no deny rule and no rule ordering: traffic is permitted if any rule matches and dropped otherwise. That makes the model unusually easy to reason about, because a group cannot contain a subtle precedence bug. It can only be too permissive or too narrow, and both are visible by reading it.

Statefulness is the property that most changes day-to-day work. The group tracks connections, so if an inbound rule permits a request, the response is permitted automatically — you never write a return rule. A workload with a permissive outbound rule and no inbound rules can call out and receive replies while accepting nothing unsolicited. This is exactly the opposite of a network ACL, and confusing the two is the subject of Network ACLs: The Stateless Filter.

Because it is attached per resource, a security group segments *inside* a subnet. Two instances in the same subnet with different groups cannot necessarily reach each other, which is the mechanism that makes subnet-level co-location safe. Routing connects everything in the network via the local route; the group is what makes that harmless.

SourceProtocol / portDestinationDecisionWhy
0.0.0.0/0 (internet)TCP 443Load balancer groupALLOWThe entry point exists to be dialable. This is the design, not a finding.
0.0.0.0/0 (internet)TCP 5432Database groupDENYA public database is the finding, regardless of password strength.
Load balancer groupTCP 8080Application groupALLOWGroup-referenced, so it survives every instance replacement.
Application groupTCP 5432Database groupALLOWThe only path to the data tier, named by group rather than by CIDR.
Application groupTCP 5432Application groupDENYPeers do not need to reach each other; lateral movement is not a feature.
0.0.0.0/0 (internet)TCP 22Any groupDENYUse a managed session service. An open 22 is a permanent brute-force target.
Bastion / session serviceTCP 22Application groupALLOW (audited)One narrow, logged path in, if direct access is genuinely unavoidable.
The design, expressed as rules. Read it as source → port → destination.

Reference groups, not address ranges

provider-specific· Group-referencing exists on all major providers under different names (security groups, application security groups, network tags); the syntax here is AWS-flavored.

The rule that separates a security-group design that ages well from one that rots is whether rules name *groups* or *addresses*. An instance's address is ephemeral: it changes on replacement, on scale-out, on a rolling deploy, and on any instance refresh. A rule written as "allow 5432 from 10.20.32.0/20" grants access to whatever happens to be in that range today — which includes the next workload someone places there, and excludes any application instance that ends up somewhere else.

A rule written as "allow 5432 from the application group" is a statement about identity rather than location. It stays correct through every replacement, it stays correct when the application moves to another subnet or zone, and it is self-documenting: reading the database's group tells you exactly which workload class may reach it. It is also narrower, because a CIDR grants access to everything in the range while a group grants it only to members.

The other half of the discipline is outbound. Most environments leave egress wide open because the default is permissive and tightening it is work. That default means a compromised application instance can reach anything on the internet, which is the working half of most data-exfiltration paths. A narrow egress rule set is more effort and it is what turns a compromise into a contained one. See Egress Security and Least Privilege in Infrastructure.

CIDR-based rules — correct on the day they are written
resource "security_group_rule" "db_in" {
  security_group_id = sg_database
  type              = "ingress"
  protocol          = "tcp"
  from_port         = 5432
  cidr_blocks       = ["10.20.32.0/20"]   # "the app subnet"
}

resource "security_group_rule" "app_out" {
  security_group_id = sg_app
  type              = "egress"
  protocol          = "-1"
  cidr_blocks       = ["0.0.0.0/0"]        # the default nobody revisits
}

# Grants 5432 to every current and future resource in that /20, including the
# batch job someone places there next quarter. And grants the app unrestricted
# outbound, which is the exfiltration path in every post-mortem.
Group-referenced rules — a statement about identity
resource "security_group_rule" "db_in" {
  security_group_id        = sg_database
  type                     = "ingress"
  protocol                 = "tcp"
  from_port                = 5432
  source_security_group_id = sg_app        # members only, wherever they run
}

resource "security_group_rule" "app_out_db" {
  security_group_id             = sg_app
  type                          = "egress"
  protocol                      = "tcp"
  from_port                     = 5432
  destination_security_group_id = sg_database
}

resource "security_group_rule" "app_out_https" {
  security_group_id = sg_app
  type              = "egress"
  protocol          = "tcp"
  from_port         = 443
  prefix_list_ids   = [pl_payment_api, pl_object_storage]   # named, not "everywhere"
}

Group references survive instance replacement and subnet moves, and they grant access to a workload class rather than to an address range that other things will move into. Narrow egress is the half that matters after a compromise, and it is the half almost everyone skips.

What a security group is not

It is not authentication. A database reachable only from the application group is still a database, and if the application is compromised the group permits exactly what it was designed to permit. Network position limits *who can attempt*, and credentials limit *who succeeds*; a design that relies on only one of them has one control, not two. See Infrastructure Trust Boundaries.

It is not a subnet control either. Because groups attach per resource, two workloads sharing a subnet can have completely different reachability, and a rule change on one has no effect on the other. That is the useful property — but it means "what can reach the data tier" is answered by reading groups, not by reading the network diagram.

And it is not egress control by default. The default outbound rule permits everything, which means the group in front of a workload is asymmetric: carefully restricted inbound, wide-open outbound. For a workload handling regulated data, the outbound half deserves the same attention, usually via an egress proxy or an allow-list of named destinations, because a security group cannot express "only this hostname".

The database's effective network policy, read as a blast-radius statement.
Database security group — attached to the primary and its replicasworkloadleast privilege
on PostgreSQL :5432 in the private data subnets
Allowed
  • Inbound TCP 5432 from the application security group
  • Inbound TCP 5432 from the migration-runner security group, used only by the deploy pipeline
  • Outbound TCP 443 to the backup service private endpoint
Actually needed
  • Inbound TCP 5432 from the application security group
  • Inbound TCP 5432 from the migration runner, ideally only during a deploy window
Explicitly denied
  • Inbound from any CIDR, including the network's own range
  • Inbound from the bastion or session service — humans reach it through an audited proxy, not directly
  • Outbound to 0.0.0.0/0

Blast radius: A compromised application instance can reach the database exactly as designed and is limited by database credentials and grants from there — which is why the network rule is one control and not the control. Nothing else in the network can open a connection at all, so a compromise elsewhere does not reach the data tier over the network.

Key points

  • Security groups are allow-only, attached per resource, and stateful — the reply to a permitted request needs no rule.
  • Rules that reference another group instead of a CIDR survive instance replacement and grant access to a workload class rather than an address range.
  • Because they attach per resource, they segment inside a subnet — which is what makes the always-present local route harmless.
  • The default outbound rule permits everything, and that default is the working half of most exfiltration paths.
  • A group limits who can attempt a connection. Credentials limit who succeeds. A design needs both.

The loop, answered

Every field is required, which is why no lesson here can recommend something without saying what it costs and what simpler thing to consider first.

How it works
  • The group is attached to a network interface; every packet to or from that interface is evaluated against it.
  • Evaluation is a match against allow rules only — no deny rules, no ordering, no precedence to reason about.
  • The connection state is tracked, so return traffic for an allowed flow is permitted without a corresponding rule.
  • A rule may name a CIDR, a prefix list, or another security group; a group reference resolves to the current membership at evaluation time.
  • Multiple groups on one interface are unioned, so adding a group can only widen access, never narrow it.
What you still own
  • Own the rule inventory as code, because a hand-added rule during an incident is the one nobody removes.
  • Own group-reference discipline: a CIDR rule between internal tiers should be treated as a review finding.
  • Own the egress half deliberately, and accept the work — narrowing outbound is where the real security gain is.
  • Own the periodic audit for 0.0.0.0/0 inbound rules on anything that is not an intended entry point.
  • Own rule-count budgets: quotas on rules per group and groups per interface are real, and prefix lists are the usual fix.
How it fails
  • A missing inbound rule: connections hang and time out, identical in appearance to a routing failure.
  • A CIDR rule that silently widens when a subnet is resized or a new workload is placed in the same range.
  • Too many groups attached to one interface, hitting a quota during a deploy rather than during design.
  • An overly narrow egress rule blocking a dependency discovered only at runtime — a package mirror, an OCSP responder, a telemetry endpoint.
  • A temporary "allow all from my office IP" rule that outlives the debugging session and the office lease.
How it scales
  • Rules per group and groups per interface are quotas; prefix lists consolidate many CIDRs into one referencable object.
  • Group references scale better than CIDR rules because membership changes require no rule change at all.
  • Very large rule sets become unreviewable long before they become slow — the limiting resource is human attention.
  • Evaluation cost is not a practical concern; the practical concern is whether anyone can still read the rules.
Security
  • This is the primary segmentation control in most cloud architectures, and it is default-deny inbound out of the box.
  • Group-referenced rules express intent, which makes an audit possible: "who can reach the database" has a readable answer.
  • It is a network control only. It does not authenticate, does not inspect payloads and cannot express hostname-level policy.
  • The default permissive egress deserves a named decision rather than an inherited default. See Egress Security.
Cost shape
  • Security groups themselves are free on every major provider.
  • The cost is indirect: rules that are too narrow cause outages, and rules that are too broad cause incidents.
  • Egress restriction can require an egress proxy or endpoints, which are real components with real bills.
  • Reviewing rules is a recurring human cost, and it grows with the number of rules — a reason to prefer group references and prefix lists.
What to watch
  • Flow logs with REJECT entries, which name the blocking rule set precisely and end most reachability debates in seconds.
  • Rule-change events, correlated with incident start times — the fastest root-cause signal in this module.
  • A periodic report of internet-facing inbound rules, diffed over time rather than reviewed once.
  • The signal that lies: a successful health check from inside the same group. It proves the process is up and says nothing about whether callers outside the group can reach it.
Simpler alternatives
  • The provider's default group, for a single-instance experiment — designing a group hierarchy for one box is wasted effort.
  • A managed platform that handles service-to-service authorization for you, when there is no virtual network to segment.
  • Application-level authentication and mutual TLS as the primary control, when workloads are ephemeral and identity-based policy fits better than address-based policy.
  • A service mesh, when policy needs to be expressed per service and per route rather than per port — at a substantial complexity cost. See Scoring Operational Complexity.
What adopting this costs
  • Buys default-deny segmentation for free; costs a rule inventory that must be reviewed or it silently rots.
  • Group references buy durability; cost a level of indirection that makes the rules slightly harder to read at a glance.
  • Narrow egress buys containment; costs runtime outages when an undiscovered dependency is blocked.

Allowed out, dropped on the way back

Allowed out, dropped on the way back
The same four rules and the same four traffic samples, evaluated first as a stateful security group and then as a stateless network ACL.
ingress rules (allow-only, unordered)
ALLOW  0.0.0.0/0 → tcp/443 → sg-lb
ALLOW  sg-lb → tcp/8080 → sg-app
ALLOW  sg-app → tcp/5432 → sg-db
(implicit)  DENY   everything else

egress rules
ALLOW  sg-* → tcp/443 → 0.0.0.0/0
(return traffic implicit — connection tracked)
r1 · the front door
r2 · load balancer to app, by group not by CIDR
r3 · the only path to the database
allowed
3
denied
1
half-open
0
tracking
yes
Internet → LB
203.0.113.9 → sg-lb:443 · matched r1
out ALLOWback ALLOWconnection works
ALLOW — and the reply on the client’s ephemeral port is allowed automatically, because the connection is tracked.
Internet → DB
203.0.113.9 → sg-db:5432 · matched no rule
out DENYback —refused at the boundary
DENY — no rule matches. Security groups are allow-only: anything not explicitly allowed is denied, silently, with no RST.
Backend → DB
sg-app → sg-db:5432 · matched r3
out ALLOWback ALLOWconnection works
ALLOW — and the reply on the client’s ephemeral port is allowed automatically, because the connection is tracked.
DB → Internet
sg-db → 0.0.0.0/0:443 · matched egress
out ALLOWback ALLOWconnection works
ALLOW — and the reply on the ephemeral port it opened is allowed automatically, because the connection is tracked.
A security group is stateful: it tracks connections, so a reply to an allowed request is allowed without any rule for it. You write one rule per intended flow, in one direction, and the return path takes care of itself. Notice Internet → DB:5432: no rule matches, so it is denied — silently. There is no RST, so a scanner sees a black hole and a misconfigured client sees a timeout, which is why "connection timed out" so often means "you forgot a rule" rather than "the service is down".
AWS-FLAVORED

What people believe, and what is true

Claim

A security group protects the subnet.

Reality

It attaches to a resource. Two instances in one subnet can have entirely different reachability, and a rule change on one does nothing to the other.

Claim

I need a rule to allow the response back.

Reality

Security groups are stateful. Return traffic for an allowed connection is permitted automatically — that is a network ACL's problem, not this one's.

Claim

The database is in a private subnet with a security group, so it is protected.

Reality

It is unreachable from the internet and reachable exactly as designed from the application. Credentials, grants and query-level authorization are still doing the rest of the work.

Apply it