I designed least-privilege cloud micro-perimeters using Azure Network Security Groups and AWS security groups, scoping each rule to specific source CIDRs and ports. The result removed every any-to-any rule and enforced tier isolation between web, app, and data subnets.

The lab runs the same three-tier design in both clouds so the differences between the two firewall models become concrete: Azure NSGs attach to subnets and NICs and evaluate rules by numeric priority, while AWS security groups attach to elastic network interfaces, are allow-only, and can reference other security groups as a source. By the end, the only internet-reachable port in either environment is 443 on the web tier, and every lateral path between tiers is explicitly permitted or explicitly denied, nothing falls through to a permissive default.

Objective & Context

Cloud NSGs and security groups are stateful, software-defined firewalls applied at the subnet or interface level. This lab builds a three-tier segmentation model where each tier only accepts traffic from the tier above, mapping to NIST SP 800-53 SC-7 and CIS cloud benchmarks.

"Stateful" matters operationally: when the web tier initiates an outbound connection to the app tier on 8080, the return traffic is automatically allowed in both clouds, so only the initiating direction needs a rule. The design goal is a deny-by-default posture in which every flow can be traced to a specific, documented rule, a property auditors look for under SC-7 (Boundary Protection) and the CIS Azure/AWS Foundations Benchmarks, both of which flag any rule exposing 0.0.0.0/0 on administrative or data ports.

  • T1190 Exploit Public-Facing Application, only the web tier exposes 443 to the internet.
  • T1210 Exploitation of Remote Services, the data tier rejects all but app-tier sources.

Segmentation directly limits those two techniques: an attacker who compromises a public web workload should find the data tier completely unreachable from that foothold, and lateral movement should generate DENY verdicts in flow logs that a SOC can alert on. The diagram shows the intended flow: internet to web on 443, web to app on 8080, app to data on 5432, and every other path denied.

Learning Objectives

  • Create Azure NSG rules implementing least-privilege tiered access.
  • Scope data-tier access to the application subnet only.
  • Write equivalent AWS security group ingress rules.
  • Read effective NSG rules, not just configured rules.
  • Enable NSG flow logs and VPC flow logs for audit.
  • Audit both clouds for residual any-any allow rules.

Environment & Prerequisites

Prerequisites

  • Azure subscription with Reader plus Network Contributor roles; an AWS account.
  • Azure CLI 2.5x and AWS CLI v2 installed and authenticated.
  • Three subnets per cloud representing web, app, and data tiers.
  • A storage account (Azure) and CloudWatch log group (AWS) for flow logs.
  • Basic understanding of CIDR and TCP ports.

Concretely, the Azure side uses one VNet (10.0.0.0/16) with web-subnet 10.0.1.0/24, app-subnet 10.0.2.0/24, and db-subnet 10.0.3.0/24, each holding one Ubuntu test VM. The AWS side mirrors this in a VPC with public, app, and data subnets. Network Contributor is required on Azure because creating NSGs and associating them to subnets writes to the network resource provider; Reader alone is enough for the audit queries at the end.

Verify both CLIs are authenticated before touching any rules:

az account show --query "{sub:name, user:user.name}" -o table && aws sts get-caller-identity
Sub          User
-----------  ---------------------------
Lab-Sub      [email protected]
{
    "UserId": "AIDAXAMPLEUSERID",
    "Account": "123456789012",
    "Arn": "arn:aws:iam::123456789012:user/taki"
}

Common mistake: running the lab against the wrong subscription or AWS profile. Both CLIs silently use whatever context is active, so confirm the account IDs above match the lab environment before creating or deleting any rules.

Rule Matrix

Before touching the CLI I write down the intended policy as a matrix. Every cell is either an explicit allow with a named rule, or "deny" by catch-all (Azure) or implicit deny (AWS). Having this on paper first is what keeps the rule set minimal, any rule that does not implement a matrix row does not get created.

SourceDestinationPortActionRule
InternetWeb tierTCP 443Allowallow-https (100)
Web tierApp tierTCP 8080Allowallow-web (100)
App tierData tierTCP 5432Allowallow-app (100)
Anything elseData tierAnyDenydeny-all-inbound (4096)
InternetApp/Data tierAnyDenyno rule / implicit deny

Note what the matrix deliberately omits: there is no SSH rule. Administrative access in this design goes through Azure Bastion and AWS Systems Manager Session Manager, so port 22 never appears in any NSG or security group. That single decision removes the most commonly brute-forced rule from the audit surface entirely.

Step-by-Step Execution

1. Create an Azure NSG rule allowing only HTTPS inbound

The web tier is the only public entry point, so its NSG gets exactly one inbound allow: TCP 443 from the internet service tag. Rule priority 100 leaves room above and below for later rules, priorities are evaluated lowest-number-first, and the first match wins. The original command from the lab run:

az network nsg rule create -g rg --nsg-name web-nsg -n allow-https --priority 100 --destination-port-ranges 443 --access Allow --protocol Tcp

Expected output:

{
  "name": "allow-https",
  "provisioningState": "Succeeded"
}

The fully-qualified form I actually recommend in scripts (explicit direction, source tag, and double-dash flags so nothing depends on shorthand parsing):

az network nsg rule create --resource-group rg-lab --nsg-name web-nsg --name allow-https --priority 100 --direction Inbound --source-address-prefixes Internet --destination-port-ranges 443 --access Allow --protocol Tcp
{
  "access": "Allow",
  "destinationPortRange": "443",
  "direction": "Inbound",
  "name": "allow-https",
  "priority": 100,
  "protocol": "Tcp",
  "sourceAddressPrefix": "Internet",
  "provisioningState": "Succeeded"
}

How to verify: provisioningState: Succeeded and the rule shows direction Inbound with priority 100. Common mistake: omitting --direction in templates and assuming it defaults to Inbound, always set it explicitly, and never reuse a priority number, because Azure rejects duplicates within the same NSG.

What this teaches: NSG rules are prioritized; the lowest number wins, so explicit allows must precede default denies.

2. Scope the data tier to the app subnet only

The data tier accepts PostgreSQL (5432) exclusively from the app subnet CIDR. Scoping by source prefix, not by "VirtualNetwork", is what turns a flat network into a segmented one. The original lab command:

az network nsg rule create -g rg --nsg-name db-nsg -n allow-app --priority 100 --source-address-prefixes 10.0.2.0/24 --destination-port-ranges 5432 --access Allow

Expected output:

{
  "name": "allow-app",
  "provisioningState": "Succeeded"
}

One allow rule is not enough on Azure, because the built-in default rule AllowVnetInBound (priority 65000) still permits every other subnet in the VNet to reach 5432. To get true tier isolation, add an explicit deny that sits above the defaults but below the allow:

az network nsg rule create --resource-group rg-lab --nsg-name db-nsg --name deny-all-inbound --priority 4096 --direction Inbound --source-address-prefixes '*' --destination-port-ranges '*' --access Deny --protocol '*'

Expected output:

{
  "access": "Deny",
  "destinationPortRange": "*",
  "direction": "Inbound",
  "name": "deny-all-inbound",
  "priority": 4096,
  "protocol": "*",
  "sourceAddressPrefix": "*",
  "provisioningState": "Succeeded"
}

Evaluation order for a packet from web-subnet to db-subnet:5432 is now allow-app (100, no match), then deny-all-inbound (4096, match, Deny), the defaults never come into play. Common mistake: writing the deny at a priority lower than 100 (numerically), which would shadow the allow rule and take the database offline; keep allows in the 100s and the catch-all deny near the end of the custom range.

What this teaches: tiering means each layer trusts only the layer above it, which contains breaches horizontally.

3. Equivalent AWS security-group ingress

AWS security groups have no priorities and no explicit deny, they are allow-only with an implicit deny at the end, so "deny everything else" is free. The powerful feature used here is source-group referencing: instead of a CIDR, the rule allows any instance attached to the app-tier SG, which survives instance replacement and autoscaling without rule edits.

aws ec2 authorize-security-group-ingress --group-id sg-db --protocol tcp --port 5432 --source-group sg-app

Expected output:

{"Return": true, "SecurityGroupRules": [{"IpProtocol": "tcp", "FromPort": 5432}]}

Confirm the rule landed with the expected referenced group. The sg-app and sg-db IDs below are the IDs returned when the groups were created (aws ec2 create-security-group --query GroupId -o text); in your run, substitute the real IDs or look them up by tag with aws ec2 describe-security-groups --filters Name=tag:Tier,Values=db --query "SecurityGroups[].GroupId":

aws ec2 describe-security-group-rules --filter Name=group-id,Values=sg-db --query "SecurityGroupRules[?IsEgress==`false`].[IpProtocol,FromPort,ToPort,ReferencedGroupInfo.GroupId]" -o table
----------------------------------
|  tcp  |  5432  |  5432  | sg-app  |
----------------------------------

Common mistake: opening 5432 to the VPC CIDR (10.0.0.0/16) "temporarily" and never tightening it, functionally identical to no segmentation. If a broad rule already exists, remove it with aws ec2 revoke-security-group-ingress rather than layering the tight rule on top.

What this teaches: AWS security groups are stateful and simpler, but the same tiering intent applies.

4. Check effective rules, not just configured rules

On Azure, a NIC can have its own NSG and inherit one from its subnet, and both are enforced (traffic must pass both). The only reliable view of what a VM actually experiences is the effective-rule list, which merges subnet and NIC NSGs:

az network nic list-effective-nsg --resource-group rg-lab --name db-vm-nic -o table

Expected output:

Access    Protocol  SourceAddressPrefix  DestinationPortRange  Priority  Direction
--------  --------  -------------------  --------------------  --------  ---------
Allow     Tcp       10.0.2.0/24          5432                  100       Inbound
Deny      *         *                    *                     4096      Inbound
Allow     *         VirtualNetwork       *                     65000     Inbound (default, shadowed)

The equivalent sanity check on AWS lists every rule on the group so nothing unexpected (like a forgotten 0.0.0.0/0 SSH rule) hides in the set:

aws ec2 describe-security-groups --group-ids sg-db --query "SecurityGroups[].IpPermissions" -o json

Expected output (one ingress permission, referencing the app tier's security group, not a CIDR):

[
  {
    "FromPort": 5432,
    "ToPort": 5432,
    "IpProtocol": "tcp",
    "UserIdGroupPairs": [{"GroupId": "sg-app"}]
  }
]

How to verify: the effective Azure list shows your allow at 100 above the deny at 4096; the AWS output shows exactly one ingress permission. Common mistake: editing the subnet NSG while a stale, more permissive NIC-level NSG still allows the traffic, list-effective-nsg exposes this immediately.

What this teaches: effective rules include defaults and subnet associations; configured rules alone lie by omission.

5. Enable flow logging in both clouds

Rules without telemetry are unverifiable. Azure virtual network flow logs (written to a storage account, one flow log per VNet rather than per NSG) record every 5-tuple with an Allow/Deny verdict; AWS VPC Flow Logs do the same to CloudWatch or S3 with ACCEPT/REJECT. Note: Microsoft stopped allowing new NSG-scoped flow logs on 2025-06-30 (full retirement 2027-09-30), so the current command targets the VNet, which is also the scope that captures east-west lateral traffic between subnets. Enable both before testing so the deny evidence exists:

az network watcher configure --locations eastus --enabled true
az network flow-log create --resource-group rg-lab --name lab-vnet-flowlog --location eastus --vnet lab-vnet --storage-account saflowlogs123
aws ec2 create-flow-logs --resource-type VPC --resource-ids vpc-0abc123 --traffic-type ALL --log-destination-type cloud-watch-logs --log-group-name /vpc/flowlogs --deliver-logs-permission-arn arn:aws:iam::123456789012:role/flowlogsRole

Expected output:

{
    "FlowLogIds": ["fl-0123abcd4567efgh8"],
    "Unsuccessful": []
}

Common mistake: Network Watcher is not automatically enabled in every region, if the first command fails with a "NetworkWatcherNotFound" style error, the az network watcher configure line above creates it. On AWS, an empty Unsuccessful array is the pass signal; entries there mean a bad IAM role or log-group ARN. The portal creates the flow log under the NetworkWatcherRG resource group regardless of which group your VNet lives in, so do not be surprised when az network flow-log show needs that group.

What this teaches: flow logs record what actually happened, which is the only way to verify a deny.

6. Test tier-to-tier connectivity

Positive test first: from the web VM, the app tier must answer on 8080. Negative test second: from the web VM, the data tier must be unreachable. Use TCP probes, not ping, ICMP is governed by different rules and proves nothing about the application paths:

curl -s -o /dev/null -w "%{http_code}\n" http://10.0.2.15:8080/health

Expected output:

200
nc -zvw3 10.0.3.20 5432
nc: connect to 10.0.3.20 port 5432 (tcp) timed out: Operation now in progress

A timeout, not "connection refused", is the expected failure mode, because a stateful deny drops the SYN silently rather than answering with a RST. Repeat the matrix in both clouds: web→app succeeds, app→db succeeds, web→db and internet→db time out. Common mistake: testing from a VM whose own guest firewall (ufw, firewalld) is also filtering; if a "should-pass" test fails, temporarily check sudo ufw status before blaming the NSG.

What this teaches: testing tier-to-tier connectivity proves the policy works in both directions.

7. Associate the NSGs to their subnets

An Azure NSG enforces nothing until it is associated, creating rules on an unattached NSG is the single most common reason a "configured" policy has zero effect. Attach each NSG to its subnet (subnet-level association is preferred over NIC-level here because it automatically covers every VM deployed into the tier, including ones added later):

az network vnet subnet update --resource-group rg-lab --vnet-name lab-vnet --name web-subnet --network-security-group web-nsg
az network vnet subnet update --resource-group rg-lab --vnet-name lab-vnet --name db-subnet --network-security-group db-nsg

Expected output:

{
  "name": "db-subnet",
  "addressPrefix": "10.0.3.0/24",
  "networkSecurityGroup": {
    "id": "/subscriptions/xxxx/resourceGroups/rg-lab/providers/Microsoft.Network/networkSecurityGroups/db-nsg"
  },
  "provisioningState": "Succeeded"
}

On AWS there is no association step for security groups: an SG applies as soon as it is attached to an instance's network interface (aws ec2 modify-instance-attribute --groups), which is why the lab attaches sg-web, sg-app, and sg-db at launch time. How to verify: the subnet JSON shows the NSG resource ID, and az network vnet subnet show on every tier returns a non-null networkSecurityGroup. Common mistake: associating the NSG to the NIC only, then autoscaling or redeploying the tier, the new VM comes up with no NSG at all.

What this teaches: associating at the subnet level scales; per-NIC rules do not.

8. Restrict egress from the data tier

Inbound rules stop attackers getting in; egress rules slow them getting data out. The data tier has no legitimate reason to initiate internet connections, so its outbound path gets an explicit deny on Azure (placed above the default AllowInternetOutBound at 65001):

az network nsg rule create --resource-group rg-lab --nsg-name db-nsg --name deny-internet-outbound --priority 200 --direction Outbound --source-address-prefixes '*' --destination-address-prefixes Internet --destination-port-ranges '*' --access Deny --protocol '*'

Expected output:

{
  "access": "Deny",
  "destinationAddressPrefix": "Internet",
  "direction": "Outbound",
  "name": "deny-internet-outbound",
  "priority": 200,
  "provisioningState": "Succeeded"
}

AWS security groups ship with a default egress rule allowing 0.0.0.0/0 on all ports, the one default rule that must be actively removed. Revoke it, then add back only what the tier needs (responses are stateful, so no return-traffic rule is required):

aws ec2 revoke-security-group-egress --group-id sg-db --protocol all --cidr 0.0.0.0/0
{
    "Return": true,
    "UnknownIpPermissions": []
}

How to verify: from the data VM, curl -m 5 https://example.com must time out, while apt-style update failures confirm the egress deny is real. If package updates are genuinely needed, proxy them through the app tier rather than re-opening direct internet egress. Common mistake: forgetting DNS, on Azure, VNet DNS resolution goes to 168.63.63.10 (a platform address, not the Internet tag), so the deny does not break name resolution; on AWS, add an explicit UDP/TCP 53 egress rule to the VPC resolver CIDR (.2 address) if workloads resolve names.

What this teaches: egress control matters as much as ingress; exfiltration leaves through outbound rules.

9. Audit for residual any-any rules

The final gate is an automated check that no rule exposes all ports to the world. Audit the custom rules of every NSG; the nested defaultSecurityRules array is a separate lookup, so check custom rules explicitly per NSG rather than trying to flatten both arrays in one JMESPath expression:

for n in web-nsg app-nsg db-nsg; do echo "== $n =="; az network nsg rule list -g rg-lab --nsg-name $n --query "[?access=='Allow' && direction=='Inbound'].{name:name, src:sourceAddressPrefix, port:destinationPortRange}" -o table; done

Expected output:

== web-nsg ==
Name         Src       Port
-----------  --------  ------
allow-https  Internet  443
== app-nsg ==
Name       Src          Port
---------  -----------  ------
allow-web  10.0.1.0/24  8080
== db-nsg ==
Name       Src          Port
---------  -----------  ------
allow-app  10.0.2.0/24  5432

AWS equivalent, any ingress permission open to the world on either IPv4 or IPv6 is a finding; note the Ipv6Ranges half, a ::/0 rule is just as open as 0.0.0.0/0 and is the audit item most teams forget:

aws ec2 describe-security-groups --query "SecurityGroups[].IpPermissions[?IpRanges[?CidrIp=='0.0.0.0/0'] || Ipv6Ranges[?CidrIpv6=='::/0']].[FromPort,ToPort,IpProtocol]" -o table
---------------------
|  443   |  443 | tcp |
---------------------

The audit passes when the only internet-facing row is the deliberate web-443 exposure (the Azure Internet service tag covers both IPv4 and IPv6 internet addresses). Common mistake: auditing only custom rules and forgetting IPv6, a ::/0 range on AWS is just as open as 0.0.0.0/0, so include Ipv6Ranges in the query in production.

What this teaches: any-any rules are the first thing auditors and attackers look for.

How It Works

Cloud NSGs and security groups are stateful, software-defined firewalls applied at the subnet or interface level. Stateful matters operationally: when the web tier initiates an outbound connection to the app tier, the return traffic is automatically allowed without an explicit inbound rule. This is why the rule sets look asymmetric compared to stateless ACLs.

Azure evaluates NSG rules by priority number (lowest first) across all NSGs affecting a NIC: subnet-level plus NIC-level, plus Azure's immutable default rules. The effective ruleset is the union, which is why list-effective-nsg exists: configured rules on one NSG never tell the whole story.

The three-tier model maps trust to network position: the web tier accepts 443 from the internet, the app tier accepts from the web tier only, and the data tier accepts from the app tier only. Flow logs in both clouds record every allowed and denied flow, turning the policy into auditable evidence and the input for anomaly detection.

Validation & Testing

From the web tier attempt a direct connection to the data tier; it must time out. Use Azure NSG flow logs and AWS VPC Flow Logs to confirm the DENY verdict. Pass criteria: only adjacent-tier traffic is permitted and no rule allows 0.0.0.0/0 except web 443.

Query the flow logs to prove the deny was enforced at the network layer rather than by chance (e.g., the service being down). For Azure, the storage-account JSON blobs show the verdict per flow; virtual network flow logs land in the insights-logs-networkflowlogevent container (NSG flow logs used insights-logs-networksecuritygroupflowevent, a common source of confusion). A quick filter with jq surfaces the denied attempts from the web subnet to 5432:

az storage blob download --account-name saflowlogs123 -c insights-logs-networkflowlogevent -n <blob-path> -f flowlog.json && jq -r '.records[].flowRecords[].flows[].flowGroups[].flowTuples[] | select(test("5432")) | select(endswith("D"))' flowlog.json
1758566400,10.0.1.10,10.0.3.20,51234,5432,T,I,D
1758566460,10.0.1.10,10.0.3.20,51236,5432,T,I,D

On AWS, a CloudWatch Logs Insights query returns the equivalent REJECT evidence:

aws logs start-query --log-group-name /vpc/flowlogs --start-time $(date -d '1 hour ago' +%s) --end-time $(date +%s) --query-string 'filter dstPort = 5432 and action = "REJECT" | stats count() by srcAddr'

Expected output:

{
  "queryId": "a1b2c3d4-5678-90ab-cdef-1234567890ab"
}

start-query only launches the query; the results arrive through a second call. Poll until the status is Complete:

aws logs get-query-results --query-id a1b2c3d4-5678-90ab-cdef-1234567890ab

Expected output:

{
  "results": [
    [{"field": "srcAddr", "value": "10.0.1.10"}, {"field": "count()", "value": "12"}]
  ],
  "status": "Complete"
}

Every timed-out probe from step 6 must appear here as D/REJECT. If the logs show no record at all, the traffic died somewhere else (routing, guest firewall) and the NSG verdict is unproven, rerun the probe while watching the logs.

The full validation matrix I ran at the end of the lab, executed once per cloud:

TestInitiatorTargetExpectedResult
Public HTTPSExternal clientweb-tier:443200 OKPass
Public HTTPExternal clientweb-tier:80TimeoutPass
Tier-to-tier appweb-vmapp-vm:8080200 OKPass
Cross-tier dataweb-vmdb-vm:5432TimeoutPass
Direct data accessExternal clientdb-vm:5432TimeoutPass
Data egressdb-vmexample.com:443TimeoutPass
SSH surfaceExternal clientany-tier:22TimeoutPass

Each "Timeout" row is cross-checked against a DENY/REJECT entry in the flow logs within a few minutes of the probe; each "200 OK" row must show an Allow/ACCEPT for the same 5-tuple. A row where the connection fails but no log verdict appears is treated as a failed test, not a pass, because the deny is then unproven.

Troubleshooting

SymptomLikely causeFix
Command not foundpackage missinginstall prerequisite and verify version
Permission deniedinsufficient privilegesuse documented sudo or lab admin context
Output differsversion or environment mismatchverify versions and variables
Service or API unreachabledependency, DNS, or firewall issuecheck status, listener, and logs
  • Rule has no effect: NSGs evaluate by priority; a lower number wins, check rule ordering. A broader allow at priority 110 silently beats a specific deny at 4096. List rules sorted by priority with az network nsg rule list -o table and renumber with az network nsg rule update --priority.
  • Unexpected allow: a subnet and NIC NSG both apply, and traffic must pass both; review effective rules with az network nic list-effective-nsg. The surprise is usually the other direction: an allow you just added in one layer is being blocked by a rule in the layer you did not edit.
  • AWS asymmetric block: security groups are stateful; NACLs are not, verify which layer drops. If a connection works outbound but the reply never returns, the subnet NACL's ephemeral-port range (1024-65535) is the usual suspect: add an inbound NACL rule for ephemeral ports, since NACLs do not track session state.
  • Flow logs show nothing: confirm Network Watcher is enabled in the region and the storage account allows the Microsoft.Insights provider; on AWS check aws ec2 describe-flow-logs for the log group, a wrong IAM role ARN creates the flow log but delivers nothing.
  • Allowed traffic still times out: work the stack top-down, effective NSG rules, then route table/UDR (a forced-tunnel route to a firewall can blackhole otherwise-allowed flows), then the guest OS firewall. Azure's Network Watcher IP Flow Verify (az network watcher test-ip-flow) pinpoints exactly which rule or hop denies a given 5-tuple.

Key Results

  • Eliminated 100% of any-to-any rules across 6 NSGs/security groups.
  • Reduced internet-exposed ports to a single 443 entry point.
  • Enforced 3-tier isolation validated by flow-log DENY verdicts.
  • Cut the data-tier reachable source set from the whole VPC to 1 app subnet.
  • Replaced static CIDR rules on AWS with security-group references, so app-tier scaling requires zero firewall changes.
  • Established a repeatable audit query (step 7) that returns one row, web 443, as the continuous compliance gate.

Key Takeaways

  • Azure NSGs match by numeric priority and include explicit deny rules; AWS security groups are allow-only with an implicit deny, design differs accordingly.
  • Azure's default AllowVnetInBound defeats naive segmentation; add a low-priority catch-all deny above the 65000 defaults.
  • Prefer security-group references (AWS) and service tags/subnet CIDRs (Azure) over broad network ranges for every non-public rule.
  • Trust effective rules, not configured rules, subnet and NIC NSGs both enforce on Azure.
  • A deny is only proven by a DENY/REJECT verdict in flow logs; enable logging before testing, not after.
  • The only acceptable internet-facing allow in a three-tier design is the web tier's service port.

Cleanup

Cloud labs keep billing until everything is torn down, and NSG-adjacent resources (public IPs, flow-log storage, CloudWatch log groups) are easy to forget. Tear down in reverse dependency order: disassociate NSGs from subnets first, then delete the NSGs, then the VMs, then logging resources.

az network vnet subnet update -g rg-lab --vnet-name lab-vnet -n db-subnet --network-security-group null && az network nsg delete -g rg-lab -n db-nsg

Expected output:

(no output on success)
aws ec2 delete-security-group --group-id sg-db && aws ec2 delete-flow-logs --flow-log-ids fl-0123abcd4567efgh8

Expected output:

(no output on success)

Because the whole Azure side lives in one resource group, az group delete -n rg-lab is the blunt but reliable fallback. Common mistake: deleting a security group that is still referenced as a source by another SG, AWS rejects the delete with a dependency violation; revoke the referencing rule first, which is also a useful confirmation of which groups are actually coupled.

Practice Challenges

Challenge 1: Break-glass access

Goal: Add a time-limited JIT rule allowing your IP to reach the data tier for 1 hour, then confirm it disappears.

Hint: Use a high-priority allow with your public IP, and remove it (or use Azure JIT) after the window.

Challenge 2: Find the shadow rule

Goal: A teammate added an any-any allow; find it using only CLI queries across both clouds.

Hint: Query for rules with 0.0.0.0/0 in source and Allow in access across all NSGs and security groups.