Network Security

Firewall Configuration Best Practices: Rules, Logging, and Audits

A practical guide to firewall configuration: stateful vs stateless firewalls, default-deny posture, rule ordering, removing stale rules, and logging denied connections for security monitoring.

December 15, 20258 min readShipSafer Team

A misconfigured firewall is often worse than no firewall because it creates a false sense of security. Teams that believe their firewall is protecting them stop looking for other exposures. Effective firewall management requires understanding the underlying model, applying default-deny as a foundation, writing precise rules, and auditing regularly to remove accumulating technical debt.

Stateful vs Stateless Firewalls

The distinction between stateful and stateless firewalls is fundamental to writing correct rules.

Stateless firewalls evaluate each packet in isolation against a set of rules. They match on source IP, destination IP, protocol, and ports — nothing else. They have no memory of previous packets in the same connection.

Stateful firewalls track connection state. They know whether a packet is part of an established connection (ESTABLISHED), a new connection being initiated (NEW), or a related connection (like FTP data channels being opened by a control channel). Most production firewalls and cloud security groups are stateful.

With a stateful firewall, you only need to allow outbound traffic for a connection. Return traffic is automatically permitted because the firewall knows it's part of an established connection:

# iptables: stateful rules for a web server
# Allow established and related connections (return traffic)
iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT

# Allow new HTTPS connections inbound
iptables -A INPUT -p tcp --dport 443 -m state --state NEW -j ACCEPT

# Allow new HTTP connections inbound (for redirect to HTTPS)
iptables -A INPUT -p tcp --dport 80 -m state --state NEW -j ACCEPT

# Allow SSH from management CIDR only
iptables -A INPUT -p tcp --dport 22 -s 10.0.100.0/24 -m state --state NEW -j ACCEPT

# Default deny all other inbound
iptables -A INPUT -j DROP

With a stateless firewall, you would also need explicit rules permitting return traffic (ephemeral ports 1024-65535 from the server back to clients).

Default-Deny: The Foundation

Every firewall should start from a default-deny posture: block everything, then explicitly allow what is needed. The alternative — default-allow with a block list — means you are always chasing new attack surface to block.

AWS Security Groups: Default-Deny Built In

AWS Security Groups are default-deny for inbound traffic (no rule = deny) and default-allow for outbound. This is the correct starting point. Common mistakes that weaken it:

# WRONG: opens SSH to the entire internet
resource "aws_security_group_rule" "ssh" {
  type        = "ingress"
  from_port   = 22
  to_port     = 22
  protocol    = "tcp"
  cidr_blocks = ["0.0.0.0/0"]  # Never do this
}

# WRONG: opens all traffic with a "temporary" rule that never gets removed
resource "aws_security_group_rule" "temp_debug" {
  type        = "ingress"
  from_port   = 0
  to_port     = 65535
  protocol    = "-1"
  cidr_blocks = ["0.0.0.0/0"]
}

The correct approach for a web server security group:

resource "aws_security_group" "web_server" {
  name        = "web-server"
  description = "Security group for web servers"
  vpc_id      = var.vpc_id

  # HTTPS from anywhere (public-facing)
  ingress {
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
    description = "HTTPS from internet"
  }

  # HTTP from anywhere (redirect to HTTPS only)
  ingress {
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
    description = "HTTP redirect to HTTPS"
  }

  # SSH from management VPN CIDR only
  ingress {
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = [var.management_cidr]
    description = "SSH from management VPN"
  }

  # Outbound: restrict to necessary destinations
  egress {
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
    description = "HTTPS to external APIs and package repos"
  }

  egress {
    from_port       = 5432
    to_port         = 5432
    protocol        = "tcp"
    security_groups = [aws_security_group.database.id]
    description     = "PostgreSQL to database SG"
  }

  # DNS (required for hostname resolution)
  egress {
    from_port   = 53
    to_port     = 53
    protocol    = "udp"
    cidr_blocks = ["0.0.0.0/0"]
    description = "DNS UDP"
  }
}

Note: Rather than cidr_blocks for inter-service communication, reference security groups. This ensures only EC2 instances in the database security group can be reached, not any IP in a CIDR block.

Rule Ordering

Firewall rules are evaluated in order. The first matching rule wins (in most implementations — iptables, nftables, Palo Alto Networks). AWS Security Groups are a notable exception: they evaluate all rules and use the least restrictive match.

iptables Rule Ordering

# Rules are evaluated top to bottom
# Put more specific rules BEFORE broader rules

# 1. Allow loopback
iptables -A INPUT -i lo -j ACCEPT

# 2. Allow established connections (fast path for return traffic)
iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT

# 3. Block specific bad actors (before any allow rules)
iptables -A INPUT -s 192.0.2.100 -j DROP
iptables -A INPUT -s 198.51.100.0/24 -j DROP

# 4. Allow specific services
iptables -A INPUT -p tcp --dport 443 -j ACCEPT
iptables -A INPUT -p tcp --dport 22 -s 10.0.100.0/24 -j ACCEPT

# 5. Log and drop everything else
iptables -A INPUT -j LOG --log-prefix "FW-DROP: " --log-level 4
iptables -A INPUT -j DROP

nftables: Modern Alternative

nftables provides better performance and cleaner syntax than iptables:

# /etc/nftables.conf
table inet filter {
    chain input {
        type filter hook input priority 0; policy drop;  # Default drop

        # Loopback
        iif lo accept

        # Established connections
        ct state established,related accept

        # ICMP (ping) — limit to prevent flood
        ip protocol icmp icmp type echo-request limit rate 10/second accept

        # HTTP and HTTPS
        tcp dport { 80, 443 } accept

        # SSH from management subnet only
        ip saddr 10.0.100.0/24 tcp dport 22 accept

        # Log and drop everything else
        log prefix "nft drop: " drop
    }

    chain output {
        type filter hook output priority 0; policy accept;
        # Restrict outbound if needed
    }

    chain forward {
        type filter hook forward priority 0; policy drop;
    }
}

Stale Rule Auditing

Firewall rules accumulate technical debt. Rules added for a temporary debugging session, a decommissioned service, or a vendor that no longer exists remain in the ruleset indefinitely. Stale rules expand attack surface without providing any benefit.

Identifying Unused Rules in AWS

Use AWS CloudWatch Logs Insights to find security group rules with zero traffic over the past 90 days. First, enable VPC Flow Logs:

aws ec2 create-flow-logs \
  --resource-type VPC \
  --resource-ids vpc-abc123 \
  --traffic-type ALL \
  --log-destination-type cloud-watch-logs \
  --log-group-name VPCFlowLogs \
  --deliver-logs-permission-arn arn:aws:iam::123456789:role/vpc-flow-log-role

Query for accepted traffic by destination port:

# CloudWatch Logs Insights query
fields @timestamp, dstPort, action
| filter action = "ACCEPT"
| stats count() as connections by dstPort
| sort connections desc

Ports with zero accepted connections over 90 days are candidates for rule removal.

iptables Rule Hit Counting

# View rules with packet/byte counts
iptables -L INPUT -v -n

# Output includes:
# pkts bytes target prot opt in out source destination
#    0     0 ACCEPT tcp -- * * 0.0.0.0/0 0.0.0.0/0 tcp dpt:8080

# Zero pkts on port 8080 means no traffic has matched this rule

Script to find zero-hit rules:

#!/bin/bash
echo "Rules with zero hits (candidates for removal):"
iptables -L INPUT -v -n | awk '{
  if ($1 == "0" && $2 == "0" && NR > 2) {
    print "  Rule:", $0
  }
}'

Quarterly Firewall Audit Process

  1. Export current ruleset: iptables-save > firewall-rules-$(date +%Y%m%d).txt
  2. Cross-reference with current services: Map each rule to a service or use case. If no one can explain why a rule exists, flag it.
  3. Check for shadow rules: Rules that match traffic that would be caught by a more general rule above it.
  4. Review deny logs: High volumes of drops from a specific IP might indicate a blocked legitimate service; low-volume drops might be normal noise.
  5. Remove stale rules in staging first: Test that removing a rule doesn't break anything before removing it in production.
  6. Document changes: Every rule should have a description, a ticket reference, and a review date.

Logging Denied Connections

Logging only allowed traffic leaves you blind to attack attempts and misconfigurations. Log denied connections for security monitoring.

iptables Logging

# Log denied packets before dropping them
# Rate-limit logging to prevent log flooding
iptables -A INPUT -m limit --limit 5/min --limit-burst 10 \
  -j LOG --log-prefix "FW-DENIED-IN: " --log-level 4

iptables -A INPUT -j DROP

The logs appear in /var/log/kern.log or /var/log/messages:

Aug 01 14:23:45 server kernel: FW-DENIED-IN: IN=eth0 OUT= SRC=203.0.113.42
  DST=10.0.1.5 PROTO=TCP SPT=54321 DPT=3306 WINDOW=65535 SYN

This shows an external IP attempting to reach MySQL (port 3306) directly — a serious finding if MySQL should only be accessible internally.

Forwarding Firewall Logs to a SIEM

Forward iptables logs to your centralized logging system:

# /etc/filebeat/filebeat.yml
filebeat.inputs:
  - type: log
    enabled: true
    paths:
      - /var/log/kern.log
    include_lines: ['FW-DENIED']
    tags: ["firewall", "denied"]
    fields:
      source_type: firewall

output.elasticsearch:
  hosts: ["https://elasticsearch.internal:9200"]
  index: "firewall-logs-%{+yyyy.MM.dd}"

Create alerts for:

  • Denied connections to database ports (3306, 5432, 27017) from external IPs
  • Port scan signatures (sequential port access from the same source IP)
  • SSH brute force patterns (many denied connections to port 22)
  • Denied connections to management ports from unexpected subnets

A firewall without logging is a fence without cameras. You know it's there, but you have no record of who tried to climb it or when.

firewall
network security
iptables
aws security groups
default deny
network policy

Check Your Security Score — Free

See exactly how your domain scores on DMARC, TLS, HTTP headers, and 25+ other automated security checks in under 60 seconds.