Home CS Fundamentals Firewalls and Proxies Explained — How Networks Stay Safe and Fast

Firewalls and Proxies Explained — How Networks Stay Safe and Fast

In Plain English 🔥
Imagine your school has a security guard at the front gate who checks everyone's ID before letting them in — that's a firewall. Now imagine the school also has a secretary who makes phone calls on your behalf so the other person never gets your direct number — that's a proxy. The firewall decides WHO gets through. The proxy decides HOW the conversation happens, and who the other side thinks they're talking to. Together they're the two-person security team of every serious network.
⚡ Quick Answer
Imagine your school has a security guard at the front gate who checks everyone's ID before letting them in — that's a firewall. Now imagine the school also has a secretary who makes phone calls on your behalf so the other person never gets your direct number — that's a proxy. The firewall decides WHO gets through. The proxy decides HOW the conversation happens, and who the other side thinks they're talking to. Together they're the two-person security team of every serious network.

Every time you open a browser at work, stream a video on a corporate Wi-Fi, or deploy an API to the cloud, there are invisible gatekeepers deciding whether your traffic is allowed, where it should go, and what the destination is allowed to know about you. Firewalls and proxies are those gatekeepers — and understanding them is the difference between a developer who ships code and one who ships secure, production-ready systems. Misunderstanding them causes real outages, security holes, and hours of debugging 'why can't my app connect?'

What a Firewall Actually Does — Beyond Just Blocking Ports

A firewall is a network security system that inspects incoming and outgoing traffic and decides whether to allow or deny it based on a predefined set of rules. Think of rules like a guest list — if your IP, port, or protocol isn't on the list, you don't get in.

There are three main generations of firewalls you'll encounter in the real world. Packet-filtering firewalls (the oldest) inspect each packet in isolation — they check source IP, destination IP, protocol, and port number. They're fast but naive: they can't tell if a packet is part of a legitimate TCP session or an attack masquerading as one.

Stateful firewalls level up by tracking connection state. They know whether a packet is the start of a new connection, part of an existing one, or completely unexpected. If you never sent a SYN to a server, that server's SYN-ACK coming back looks suspicious — a stateful firewall will drop it.

Application-layer firewalls (often called Next-Generation Firewalls or NGFWs) go even deeper — they can inspect HTTP headers, DNS queries, and even TLS metadata to make decisions based on content, not just packets. This is how corporate firewalls block TikTok even when it runs on standard HTTPS port 443.

SimplePacketFilter.py · PYTHON
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182
# Simulating a basic stateless packet-filtering firewall in Python.
# In production, this logic lives in kernel space (iptables, nftables, Windows Firewall).
# Here we model it in userspace so you can SEE the decision process.

from dataclasses import dataclass
from typing import List

@dataclass
class NetworkPacket:
    source_ip: str
    destination_ip: str
    destination_port: int
    protocol: str  # 'TCP', 'UDP', 'ICMP'

@dataclass
class FirewallRule:
    """
    A single firewall rule. Rules are evaluated top-to-bottom.
    The first matching rule wins — just like real iptables chains.
    """
    source_ip: str        # '*' means any
    destination_port: int # -1 means any
    protocol: str         # '*' means any
    action: str           # 'ALLOW' or 'DENY'

class PacketFilterFirewall:
    def __init__(self, default_policy: str = 'DENY'):
        """
        Default policy is 'DENY' — this is the secure default.
        'Allow all, deny specific' is the WRONG approach (allowlist vs denylist).
        """
        self.rules: List[FirewallRule] = []
        self.default_policy = default_policy

    def add_rule(self, rule: FirewallRule):
        # Rules are ordered — first match wins, so order matters enormously
        self.rules.append(rule)

    def inspect(self, packet: NetworkPacket) -> str:
        for rule in self.rules:
            # Check each condition — '*' is a wildcard that matches anything
            ip_match = (rule.source_ip == '*' or rule.source_ip == packet.source_ip)
            port_match = (rule.destination_port == -1 or rule.destination_port == packet.destination_port)
            proto_match = (rule.protocol == '*' or rule.protocol == packet.protocol)

            if ip_match and port_match and proto_match:
                # Found the first matching rule — enforce it immediately
                return rule.action

        # No rule matched — fall back to the default policy
        return self.default_policy

# --- Setting up the firewall ---
firewall = PacketFilterFirewall(default_policy='DENY')

# Rule 1: Allow all HTTP traffic from anywhere
firewall.add_rule(FirewallRule(source_ip='*', destination_port=80, protocol='TCP', action='ALLOW'))

# Rule 2: Allow HTTPS from anywhere
firewall.add_rule(FirewallRule(source_ip='*', destination_port=443, protocol='TCP', action='ALLOW'))

# Rule 3: Allow SSH only from the trusted admin IP
firewall.add_rule(FirewallRule(source_ip='10.0.0.5', destination_port=22, protocol='TCP', action='ALLOW'))

# Rule 4: Explicitly block a known malicious IP on any port
firewall.add_rule(FirewallRule(source_ip='185.220.101.9', destination_port=-1, protocol='*', action='DENY'))

# --- Testing packets against the firewall ---
test_packets = [
    NetworkPacket('203.0.113.42', '192.168.1.1', 80,  'TCP'),  # Regular web request
    NetworkPacket('203.0.113.42', '192.168.1.1', 443, 'TCP'),  # HTTPS request
    NetworkPacket('198.51.100.7', '192.168.1.1', 22,  'TCP'),  # SSH from unknown IP
    NetworkPacket('10.0.0.5',     '192.168.1.1', 22,  'TCP'),  # SSH from trusted admin
    NetworkPacket('185.220.101.9','192.168.1.1', 443, 'TCP'),  # Known bad actor on HTTPS
    NetworkPacket('203.0.113.42', '192.168.1.1', 8080,'TCP'),  # Non-standard port — no rule
]

print(f"{'Source IP':<20} {'Port':<6} {'Result'}")
print('-' * 40)
for pkt in test_packets:
    result = firewall.inspect(pkt)
    print(f"{pkt.source_ip:<20} {pkt.destination_port:<6} {result}")
▶ Output
Source IP Port Result
----------------------------------------
203.0.113.42 80 ALLOW
203.0.113.42 443 ALLOW
198.51.100.7 22 DENY
10.0.0.5 22 ALLOW
185.220.101.9 443 DENY
203.0.113.42 8080 DENY
⚠️
Watch Out: Rule Order Is EverythingIf you put the 'ALLOW * port 443' rule BEFORE your 'DENY 185.220.101.9' rule, the malicious IP slips through — the first matching rule wins and evaluation stops. Always put your most specific DENY rules above broad ALLOW rules, just like the code above does.

Forward Proxies vs Reverse Proxies — Two Tools With Opposite Jobs

The word 'proxy' trips people up because it means two completely different things depending on which side of the connection it sits on. Getting this wrong in an interview is an instant red flag.

A forward proxy sits between your users and the internet. Your client makes a request to the proxy, and the proxy makes the real request on the client's behalf. The destination server sees the proxy's IP, not yours. This is how corporate networks enforce browsing policies (blocking social media), how VPNs mask your origin, and how Tor anonymizes traffic. The key insight: the CLIENT knows about the forward proxy.

A reverse proxy sits in front of your servers, facing the internet. Clients think they're talking directly to your backend, but they're actually talking to the proxy. The proxy decides which backend server handles the request. The key insight: the CLIENT does not know about the reverse proxy — they think they're hitting your server directly. Nginx, Cloudflare, and AWS ALB are all reverse proxies in disguise.

The mental model: a forward proxy protects and controls the CLIENT. A reverse proxy protects and controls the SERVER. Both hide one side from the other — they just hide different sides.

ProxyBehaviorDemo.py · PYTHON
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108
# This demo simulates the REQUEST FLOW through both a forward and reverse proxy.
# We use Python's http.server and threading to run real local HTTP servers.
# Run this script and watch the printed logs to see who talks to whom.

import threading
import time
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.request import urlopen, Request
from urllib.error import URLError

# ─────────────────────────────────────────────────
# 1. THE ORIGIN SERVER — represents your backend API
# ─────────────────────────────────────────────────
class OriginServerHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        # Log who is connecting — in a reverse proxy setup, this will be
        # the proxy's IP, NOT the original client's IP
        print(f"[ORIGIN SERVER] Received request from: {self.client_address[0]}")
        print(f"[ORIGIN SERVER] Path requested: {self.path}")

        response_body = b"Hello from the Origin Server! Path: " + self.path.encode()
        self.send_response(200)
        self.send_header('Content-Type', 'text/plain')
        self.send_header('Content-Length', len(response_body))
        self.end_headers()
        self.wfile.write(response_body)

    # Suppress default request logging to keep output clean
    def log_message(self, format, *args): pass

# ─────────────────────────────────────────────────
# 2. THE REVERSE PROXY — sits in front of origin
# ─────────────────────────────────────────────────
class ReverseProxyHandler(BaseHTTPRequestHandler):
    ORIGIN_SERVER_URL = 'http://127.0.0.1:9001'
    BLOCKED_PATHS = ['/admin', '/internal']

    def do_GET(self):
        print(f"\n[REVERSE PROXY] Client {self.client_address[0]} wants: {self.path}")

        # Block sensitive internal paths — client never knows these exist
        if self.path in self.BLOCKED_PATHS:
            print(f"[REVERSE PROXY] BLOCKING sensitive path: {self.path}")
            self.send_response(403)
            self.end_headers()
            self.wfile.write(b"403 Forbidden")
            return

        # Forward the request to the origin server on the client's behalf
        # The origin server will see 127.0.0.1, not the real client IP
        target_url = self.ORIGIN_SERVER_URL + self.path
        print(f"[REVERSE PROXY] Forwarding to origin: {target_url}")

        try:
            with urlopen(Request(target_url), timeout=5) as origin_response:
                body = origin_response.read()

            # Pass the origin's response back to the original client
            self.send_response(200)
            self.send_header('Content-Type', 'text/plain')
            self.send_header('X-Served-By', 'TheCodeForge-ReverseProxy')  # Custom header
            self.end_headers()
            self.wfile.write(body)
            print(f"[REVERSE PROXY] Successfully relayed {len(body)} bytes back to client")

        except URLError as network_error:
            print(f"[REVERSE PROXY] Origin server unreachable: {network_error}")
            self.send_response(502)  # 502 Bad Gateway — classic reverse proxy error
            self.end_headers()
            self.wfile.write(b"502 Bad Gateway")

    def log_message(self, format, *args): pass

# ─────────────────────────────────────────────────
# 3. SPIN UP BOTH SERVERS IN BACKGROUND THREADS
# ─────────────────────────────────────────────────
def start_server(handler_class, port):
    server = HTTPServer(('127.0.0.1', port), handler_class)
    server.serve_forever()

origin_thread = threading.Thread(target=start_server, args=(OriginServerHandler, 9001), daemon=True)
proxy_thread  = threading.Thread(target=start_server, args=(ReverseProxyHandler,  9000), daemon=True)

origin_thread.start()
proxy_thread.start()
time.sleep(0.5)  # Give servers a moment to bind their ports

# ─────────────────────────────────────────────────
# 4. SIMULATE CLIENT REQUESTS (client talks to PROXY only)
# ─────────────────────────────────────────────────
print("=" * 55)
print("CLIENT: Requesting /api/users through the reverse proxy")
print("=" * 55)
with urlopen('http://127.0.0.1:9000/api/users', timeout=5) as resp:
    print(f"CLIENT received: {resp.read().decode()}")

time.sleep(0.2)

print("\n" + "=" * 55)
print("CLIENT: Attempting to access /admin (should be blocked)")
print("=" * 55)
try:
    urlopen('http://127.0.0.1:9000/admin', timeout=5)
except Exception as e:
    print(f"CLIENT received error (expected): HTTP 403")

time.sleep(0.2)
print("\nDemo complete.")
▶ Output
=======================================================
CLIENT: Requesting /api/users through the reverse proxy
=======================================================
[REVERSE PROXY] Client 127.0.0.1 wants: /api/users
[REVERSE PROXY] Forwarding to origin: http://127.0.0.1:9001/api/users
[ORIGIN SERVER] Received request from: 127.0.0.1
[ORIGIN SERVER] Path requested: /api/users
[REVERSE PROXY] Successfully relayed 43 bytes back to client
CLIENT received: Hello from the Origin Server! Path: /api/users

=======================================================
CLIENT: Attempting to access /admin (should be blocked)
=======================================================
[REVERSE PROXY] Client 127.0.0.1 wants: /admin
[REVERSE PROXY] BLOCKING sensitive path: /admin
CLIENT received error (expected): HTTP 403

Demo complete.
⚠️
Pro Tip: The X-Forwarded-For HeaderWhen a reverse proxy forwards a request, the origin server loses the real client IP. The industry standard fix is the X-Forwarded-For header — the proxy injects it with the original client's IP so your backend logs remain accurate. Always check this header in your app if you're behind Nginx, Cloudflare, or a load balancer, or your rate-limiting and geo-blocking will target the proxy's IP instead of the real user.

How Firewalls and Proxies Work Together in Real Architectures

In production, firewalls and proxies don't compete — they layer. Each handles a different concern, and combining them is what gives you defense in depth. Here's the pattern you'll see in virtually every serious web company.

At the network perimeter, a stateful firewall (hardware or cloud security group like AWS's Security Groups) allows only ports 80 and 443 inbound from the internet. Everything else is dropped at the packet level — attackers can't even probe your database port because the firewall silently discards the packets.

Behind that, a reverse proxy (Nginx, HAProxy, or a cloud load balancer) terminates TLS, inspects HTTP, and routes traffic to the right backend service. It also rate-limits, caches responses, and handles DDoS mitigation. Your actual backend servers aren't even directly reachable from the internet — they live in a private subnet.

Optionally, a Web Application Firewall (WAF) sits inline with the reverse proxy and inspects HTTP payloads specifically for application-layer attacks: SQL injection strings in query parameters, XSS payloads in headers, path traversal attempts. A WAF is essentially an application-layer firewall bolted onto a reverse proxy.

For outbound corporate traffic, a forward proxy (Squid, Zscaler) ensures employees' internet requests are logged, filtered, and controlled — and that your internal server IPs are never exposed to the outside world.

reverse_proxy_with_access_control.conf · NGINX
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960
# Production-grade Nginx config that acts as both a reverse proxy AND
# an application-layer access control layer.
# This sits in front of a Node.js app running on port 3000 internally.

# --- Rate limiting zone: track clients by IP, 10MB memory, max 30 req/min ---
limit_req_zone $binary_remote_addr zone=api_rate_limit:10m rate=30r/m;

server {
    listen 443 ssl http2;
    server_name api.yourcorp.com;

    # TLS termination happens HERE — backend never handles raw TLS
    ssl_certificate     /etc/ssl/certs/yourcorp.crt;
    ssl_certificate_key /etc/ssl/private/yourcorp.key;
    ssl_protocols       TLSv1.2 TLSv1.3;  # Reject weak TLS 1.0 and 1.1

    # ── Block internal/admin paths from public internet ──────────────────────
    location ~ ^/(internal|metrics|health/debug) {
        # Only allow requests from the internal VPC CIDR range
        allow 10.0.0.0/8;
        deny  all;  # Everyone else gets 403 — firewall at the HTTP layer
    }

    # ── Public API — apply rate limiting ─────────────────────────────────────
    location /api/ {
        # Apply rate limit — burst of 10 requests allowed before throttling
        limit_req zone=api_rate_limit burst=10 nodelay;

        # THE CORE PROXY ACTION: forward to backend, hide backend's identity
        proxy_pass http://127.0.0.1:3000;

        # Tell the backend the REAL client IP (not Nginx's loopback address)
        proxy_set_header X-Forwarded-For $remote_addr;
        proxy_set_header X-Forwarded-Proto $scheme;

        # Strip headers the backend might leak to clients
        proxy_hide_header X-Powered-By;  # Don't expose 'Express' or 'Node'
        proxy_hide_header Server;        # Don't expose backend server version

        # Timeout settings — don't let slow backends hold connections forever
        proxy_connect_timeout 5s;
        proxy_read_timeout    30s;
    }

    # ── Static assets — serve directly, never hit the backend ────────────────
    location /static/ {
        root /var/www/yourcorp;
        expires 30d;       # Cache for 30 days — no need to hit backend
        add_header Cache-Control "public, immutable";
    }

    # ── Redirect all HTTP to HTTPS at the server level ───────────────────────
    # (The firewall allows port 80 inbound only for this redirect)
}

server {
    listen 80;
    server_name api.yourcorp.com;
    return 301 https://$host$request_uri;  # Permanent redirect to HTTPS
}
▶ Output
# No runtime output — this is a config file.
# When a client hits https://api.yourcorp.com/api/users:
# 1. Nginx terminates TLS (backend sees plain HTTP on port 3000)
# 2. X-Forwarded-For is set to the real client IP
# 3. Rate limiter checks: if >30 req/min, returns HTTP 429
# 4. Request proxied to 127.0.0.1:3000
# 5. X-Powered-By and Server headers stripped from response
#
# When a client hits https://api.yourcorp.com/metrics:
# → 403 Forbidden (unless from 10.0.0.0/8 range)
#
# When a client hits http://api.yourcorp.com/anything:
# → 301 Redirect to https://api.yourcorp.com/anything
🔥
Interview Gold: Defense in DepthWhen an interviewer asks 'how would you secure a web API?', don't just say 'add authentication'. Walk through the layers: network firewall blocks unwanted ports → reverse proxy terminates TLS and rate-limits → WAF inspects HTTP payloads → app-level auth validates tokens. Each layer stops a different class of attack. This layered thinking is what separates a junior from a mid-senior engineer.
Feature / AspectFirewallProxy (Reverse)
Primary jobAllow or deny traffic based on rulesRoute and relay traffic between clients and servers
Operates at OSI layerLayer 3-4 (packet/transport) or Layer 7 (NGFW)Layer 7 (application — HTTP, HTTPS, WebSocket)
Sees packet content?Only with NGFW / DPI enabledYes — always reads HTTP headers and request path
Hides server identity?No — it blocks, but IP may still be probedYes — clients talk to proxy IP, not backend IP
TLS termination?No (unless specifically a TLS inspection proxy)Yes — standard feature in Nginx, HAProxy, ALB
Rate limiting?Only at IP level (connection rate)Yes — per-route, per-header, per-user-agent
Caching responses?NoYes — Nginx, Varnish, Cloudflare all cache
Typical real toolsiptables, AWS Security Groups, pfSense, Palo AltoNginx, HAProxy, Cloudflare, AWS ALB, Traefik
Set up byNetwork / DevOps / Security engineerDevOps / Backend engineer
First line of defense?Yes — blocks at the network perimeterSecond layer — after network firewall

🎯 Key Takeaways

  • A firewall controls WHETHER traffic is allowed; a proxy controls HOW traffic is relayed — they solve different problems and should be layered together, not treated as alternatives.
  • Rule order in a firewall is not cosmetic — the first matching rule wins and evaluation stops. A misplaced ALLOW rule above a DENY rule can silently negate your entire security policy.
  • A reverse proxy is your server's bodyguard: it terminates TLS, hides your backend topology, rate-limits abusive clients, strips leaky headers, and can cache responses — all before a single byte reaches your application code.
  • X-Forwarded-For is useful for preserving client IP through a proxy chain, but it's trivially spoofable if you don't restrict which IPs are allowed to set it. Always validate it against known proxy addresses before trusting it for security logic.

⚠ Common Mistakes to Avoid

  • Mistake 1: Confusing forward and reverse proxies in interviews — Saying 'a proxy hides the client' is only half true and will get you corrected. A forward proxy hides the client from the server; a reverse proxy hides the server from the client. The fix: always state which direction the proxy faces. 'This is a reverse proxy — it sits in front of our servers. Clients have no idea our backends are Node.js instances on port 3000.'
  • Mistake 2: Trusting X-Forwarded-For blindly for security decisions — If your app uses X-Forwarded-For for rate limiting or geo-blocking, an attacker can spoof it by sending 'X-Forwarded-For: 127.0.0.1' in their request and bypass your controls entirely. The fix: only trust this header if it was set by YOUR proxy. In Nginx, use '$realip_module' with 'set_real_ip_from' pointing to your known proxy IPs — then use '$remote_addr' instead of the raw header value.
  • Mistake 3: Using a default-ALLOW firewall policy — Setting up a firewall that blocks specific bad IPs but allows everything else (a denylist) sounds logical but is fundamentally insecure. You can never enumerate all bad actors. The fix: always default to DENY ALL, then explicitly allow only what your application needs (an allowlist). This is sometimes called 'whitelist by default' — it means an attacker probing an unknown port gets silence, not an open door.

Interview Questions on This Topic

  • QWhat's the difference between a forward proxy and a reverse proxy? Can you give a real-world example of each, and explain what each side of the connection knows about the other?
  • QIf a client sends a request through a reverse proxy to your backend, and your backend logs show every request is coming from 127.0.0.1, what's happening and how do you fix it?
  • QA stateless packet-filtering firewall is blocking all traffic on port 443, but your HTTPS app is still getting hit with SQL injection attempts. Why isn't the firewall stopping it, and what layer of defense would actually catch it?

Frequently Asked Questions

What is the difference between a firewall and a proxy server?

A firewall enforces rules about which network traffic is permitted at all — it's the gatekeeper deciding if a connection should exist. A proxy server relays traffic on behalf of one party to another, hiding the original requester or the backend server. Firewalls operate primarily at the network and transport layer; proxies operate at the application layer and can read HTTP headers, paths, and cookies.

Does a VPN work like a proxy?

A VPN is closer to a forward proxy in concept — both mask your real IP from the destination server. The critical difference is scope: a proxy typically handles only one protocol (like HTTP), while a VPN tunnels ALL network traffic at the OS level using an encrypted tunnel. A VPN also encrypts traffic between you and the VPN server, whereas a basic forward proxy does not.

Can a firewall stop application-layer attacks like SQL injection?

A standard stateful firewall cannot — it makes decisions based on IP addresses, ports, and connection state, not the content of HTTP payloads. SQL injection hides inside a perfectly valid TCP connection on port 443. To catch it, you need a Web Application Firewall (WAF), which operates at Layer 7 and inspects the actual request payload for attack patterns. Think of it as a firewall specifically trained to read and understand HTTP.

🔥
TheCodeForge Editorial Team Verified Author

Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.

← PreviousNetwork Security BasicsNext →VPN Explained
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged