Why does Squid log TCP_DENIED/403 for every request?
A fresh Squid allows only localhost: allow localnet ships commented out. The rule order, each distro's default, and how to see which line denied you.
Key points
- Squid checks
http_accesslines from the top and stops at the first one that matches. On a fresh Debian 13, Ubuntu 24.04, Ubuntu 26.04 or Alpine 3.24 install,http_access allow localnetis commented out, so every client except localhost reacheshttp_access deny alland getsTCP_DENIED/403. - The fix is an allow rule above
deny all. On Debian and Ubuntu, a file in/etc/squid/conf.d/is read at that spot. A line added at the end ofsquid.confis never reached, andsquid -k parsedoes not warn about it. - If no line matches, Squid does the opposite of the last line; with no lines at all, it denies. Ending the list with
http_access deny allremoves the guesswork. debug_options ALL,1 28,3makescache.logname the line:checked: http_access#8 = 1. The access log line and the 403 page never say which rule refused you.- Even an allowed client gets 403 for
CONNECTto ports other than 443 (SSL_ports), for ports outsideSafe_ports, and for127.0.0.1,::1,0.0.0.0and169.254.0.0/16destinations. Those stock deny lines sit above the conf.d include.
Squid logs TCP_DENIED/403 for every request when no http_access line allows the client. On a fresh install, that is every client except the Squid machine itself. The packaged squid.conf on Debian 13, Ubuntu 24.04, Ubuntu 26.04 and Alpine 3.24 ships http_access allow localnet commented out. So for a request from any other machine, the first rule that matches is the last one, http_access deny all. The fix is an allow rule placed above that line. On Debian and Ubuntu the clean place is a file in /etc/squid/conf.d/, because the include line sits just above deny all:
# /etc/squid/conf.d/localnet.conf
http_access allow localnetThen run squid -k parse and squid -k reconfigure. Do not add the line at the bottom of squid.conf. Squid stops at the first rule that matches, deny all matches everything, and a rule below it never runs.
access.log shows | The client sees | Stock line that matched | Fix |
|---|---|---|---|
TCP_DENIED/403 ... GET http://... from any machine but the proxy | Squid page "Access Denied.", header X-Squid-Error: ERR_ACCESS_DENIED 0 | http_access deny all | http_access allow localnet (or your own ACL) above deny all |
TCP_DENIED/403 ... CONNECT host:443 from the same machine | curl (56) CONNECT tunnel failed, response 403; Chrome ERR_TUNNEL_CONNECTION_FAILED | http_access deny all | Same fix |
TCP_DENIED/403 after you added an allow at the end of squid.conf | Same 403 | http_access deny all, still | Move the allow above deny all |
TCP_DENIED/403 ... CONNECT host:8443 from an allowed client | curl (56); Chrome ERR_TUNNEL_CONNECTION_FAILED | deny CONNECT !SSL_ports | acl SSL_ports port 8443 |
TCP_DENIED/403 ... GET http://host:22/ from an allowed client | "Access Denied." page | deny !Safe_ports | Usually none: the port is refused on purpose |
TCP_DENIED/403 for 127.0.0.1, localhost, [::1] or 0.0.0.0 from an allowed client | "Access Denied." page | deny to_localhost | A narrow allow above that line, in squid.conf itself |
TCP_DENIED/403 for 169.254.169.254 | "Access Denied." page | deny to_linklocal | None: it guards cloud metadata |
TCP_DENIED_REPLY/403 | "Access Denied." page | http_reply_access | That directive, not http_access |
TCP_MISS/403 | Squid error page, header X-Squid-Error: ERR_FORWARDING_DENIED 0 | miss_access | That directive |
TCP_DENIED/407 | A sign-in prompt | An authentication ACL | Credentials, not access rules |
What TCP_DENIED/403, an ACL and http_access mean#
Squid is usually a forward proxy: it sits in front of clients and fetches sites for them. That is the opposite of a reverse proxy, as set out in forward proxy vs reverse proxy. It writes one line per request to access.log. In TCP_DENIED/403, the first half is the result: Squid refused the request before fetching anything. The second half is the status it sent back. The HIER_NONE/- near the end of the line confirms that no server was contacted.
The refusal comes from two kinds of configuration line.
An ACL is a named test. acl localnet src 10.0.0.0/8 defines a test called localnet that is true when the client's address is in 10.0.0.0/8. Several values for one name are joined with OR. The stock localnet lists eight ranges, including all of 10.0.0.0/8, 172.16.0.0/12 and 192.168.0.0/16.
An http_access line is a decision: allow or deny, followed by one or more ACL names. All the names on one line must match, so they are joined with AND. http_access deny CONNECT !SSL_ports means "refuse if the method is CONNECT and the port is not in SSL_ports".
Six ACLs exist without being written down. The acl reference lists them: "ACLs all, manager, localhost, to_localhost, to_linklocal, and CONNECT are predefined." Squid's v6 source defines localhost as src 127.0.0.1/32 ::1, to_localhost as dst 127.0.0.0/8 0.0.0.0/32 ::1/128 ::/128, and to_linklocal as dst 169.254.0.0/16 fe80::/10. src tests the client's address. dst tests the address the request is going to.
The three rules Squid applies#
The http_access reference and the SquidAcl FAQ give the whole logic in three statements.
- Top to bottom, first match wins. "Access list rules are checked in the order they are written. List searching terminates as soon as one of the rules is a match."
- No match means the opposite of the last line. "If none of the 'access' lines cause a match, the default is the opposite of the last line in the list. If the last line was deny, the default is allow."
- No lines at all means deny. "If there are no 'access' lines present, the default is to deny the request."
Rule 2 is the trap. A configuration containing only http_access deny office lets in every client outside office, because the last line was a deny. One containing only http_access allow office shuts everyone else out. Both behaved that way in testing. With debugging on, Squid's log said answer ALLOWED for implicit rule won and answer DENIED for implicit rule won. The reference's advice is to avoid the question: "it is a good idea to have an 'deny all' entry at the end of your access lists". Every stock configuration does.
How the cases were reproduced#
Everything below was observed on 1 November 2026 unless it is attributed to documentation. Squid was installed from each distribution's own packages and run with the shipped squid.conf:
- Debian 13: squid 6.13-2+deb13u3.
- Ubuntu 24.04: squid 6.14-0ubuntu0.24.04.5.
- Ubuntu 26.04: squid 7.7-0ubuntu0.26.04.1, from
resolute-updates. - Alpine 3.24.2: squid 7.6-r0.
- Canonical's
ubuntu/squid:latestimage, pulled that day. It reported Squid 6.13 on Ubuntu 24.04.3.
So Ubuntu 26.04 and Alpine now ship Squid 7, not 6. Each proxy ran in a container on one Docker network. The client was curl 8.14.1 in a second container at 10.201.13.3, an address inside the stock localnet ranges. Requests went to http://example.com/, https://example.com/ and a few test ports. The browser rows come from ERR_TUNNEL_CONNECTION_FAILED: what the proxy answered, which tested Chromium against Squid 6.12; no browser was run for this page.
What each distribution ships#
| Package | Squid | http_access allow localnet | conf.d include above deny all | Client at 10.201.13.3 |
|---|---|---|---|---|
| Debian 13 | 6.13 | Commented out | Yes: include /etc/squid/conf.d/*.conf | 403 for GET and CONNECT |
| Ubuntu 24.04 | 6.14 | Commented out | Yes | 403 for GET and CONNECT |
| Ubuntu 26.04 | 7.7 | Commented out | Yes | 403 for GET and CONNECT |
| Alpine 3.24 | 7.6 | Commented out | No include line | 403 for GET and CONNECT |
ubuntu/squid:latest image | 6.13 | Commented out in squid.conf, uncommented in conf.d/debian.conf | Yes | 200 for GET and CONNECT |
A request from the proxy machine itself, through 127.0.0.1:3128, got 200 on all five.
Debian says why in its own file. /etc/squid/conf.d/debian.conf carries the comment "For extra security Debian packages only allow localhost to use the proxy on new installs", followed by #http_access allow localnet. The Canonical image ships the same file with the # removed. A container built from it serves any client in the private ranges, while a VM built from Ubuntu's package refuses them. That difference is easy to miss when a setup moves from a container to a VM.
All four package configurations keep the eight lines the http_access reference suggests, in this order:
http_access deny !Safe_ports
http_access deny CONNECT !SSL_ports
http_access allow localhost manager
http_access deny manager
http_access allow localhost
http_access deny to_localhost
http_access deny to_linklocal
http_access deny allSquid's comment above them gives the reason for allowing only localhost: "a more permissive Squid installation could introduce new attack vectors into the network by proxying external TCP connections to unprotected services."
Worked case: one request through the eight lines#
Take a laptop at 10.201.13.3 asking a fresh Debian 13 Squid for http://example.com/. Walk it down the list.
deny !Safe_ports. Port 80 is inSafe_ports, so!Safe_portsis false. No match.deny CONNECT !SSL_ports. This is a GET, not a CONNECT. No match.allow localhost manager. The client is not127.0.0.1. No match.deny manager. The URL is not a cache manager URL. No match.allow localhost. Still not127.0.0.1. No match.deny to_localhost. Squid has to resolveexample.comto answer this one. The addresses are public, so no match.deny to_linklocal. Not169.254.0.0/16orfe80::/10. No match.deny all.allmatches everything. Denied, 403.
Nothing in that walk is about the website. The laptop was refused because no line said yes to its address.
Now add /etc/squid/conf.d/localnet.conf with http_access allow localnet. The include sits between lines 7 and 8, so the new rule becomes line 8 and deny all becomes line 9. The same request stops at line 8, and 10.201.13.3 is in localnet, so it is allowed. The same laptop asking for http://127.0.0.1:8000/ still stops at line 6, because that line comes first. Asking for https://example.com:8443/, it stops at line 2. The allow you added only covers what the stock deny lines above it let through.
Why a rule after deny all never runs#
The commonest failed fix is to append http_access allow localnet to the end of squid.conf. In testing, that left the client at 403 and the debug trace still showed line 8, deny all, as the match. squid -k parse accepted the file with no warning on 6.13 and 7.7. It printed Processing: http_access allow localnet and nothing else. The parser does not tell you that a rule can never be reached.
Alpine has no conf.d include, so edit squid.conf itself. Put the rule where the shipped comment says, "INSERT YOUR OWN RULE(S) HERE TO ALLOW ACCESS FROM YOUR CLIENTS", which is above deny all.
conf.d files run in name order, at the include's position#
On Debian and Ubuntu, include /etc/squid/conf.d/*.conf comes after deny to_linklocal and before deny all. Squid reads the matching files in name order, and their rules are numbered as if they were written at that spot. Two consequences followed in testing.
Name order is rule order. With localnet.conf allowing localnet and zz-block.conf denying .example.com, the client still reached example.com with 200. The allow came first. Renaming the block file to 00-block.conf gave 403. Give deny files names that sort first.
A conf.d allow cannot undo a stock deny. Lines 1, 2, 6 and 7 come before the include. To let a client through one of them, change the ACL it tests, or edit squid.conf above that line. ACL values can be added anywhere. acl SSL_ports port 8443 in a conf.d file extended the existing SSL_ports. A CONNECT example.com:8443 that line 2 had refused then opened, logged TCP_TUNNEL/200.
What the client sees#
For an http:// URL, Squid sends its own 403 page:
HTTP/1.1 403 Forbidden
Server: squid/6.13
Content-Type: text/html;charset=utf-8
X-Squid-Error: ERR_ACCESS_DENIED 0The page reads "Access Denied. Access control configuration prevents your request from being allowed at this time." The text is the same whichever line refused you, and so is the header.
For an https:// URL, the client first sends CONNECT example.com:443. Squid refuses it with the same 403 and the same X-Squid-Error header, but no browser shows that body. curl reports curl: (56) CONNECT tunnel failed, response 403. Chrome shows ERR_TUNNEL_CONNECTION_FAILED and hides the proxy's answer on purpose. The CONNECT exchange itself is explained in HTTP CONNECT tunnelling. To read the real status, run curl -v -x http://proxy:3128 https://example.com/ from the same machine; the flags are in curl through a proxy.
The same applies to CONNECT on a port outside SSL_ports. That port list holds only 443, so https://app.internal:8443/ fails through a stock Squid even after localnet is allowed. The access log shows TCP_DENIED/403 ... CONNECT example.com:8443, and line 2 is the match.
Line 1 works the same way for every method. Safe_ports holds 80, 21, 443, 70, 210, 280, 488, 591, 777 and 1025 to 65535, so http://example.com:22/ got 403 at line 1 even for an allowed client. The low ports are left out on purpose. Adding 22 or 25 turns the proxy into a relay for SSH or mail, which is how open proxies get abused.
to_localhost: why the proxy refuses a service on its own machine#
Squid 6 changed the default order. Its release notes, under "Block to-local Traffic", introduce to_linklocal and set the default to http_access allow localhost, then deny to_localhost, then deny to_linklocal. They add: "These changes only affect the default squid.conf and new installs. Upgraded installations will continue to use their previous settings." So an install upgraded from an older Squid may not have these lines.
With the new order, a client on the Squid machine can reach local services, and nobody else can. From the laptop, with localnet allowed, http://127.0.0.1:8000/, http://localhost:8000/, http://[::1]:8001/ and http://0.0.0.0:8001/ all got 403 at line 6. The same 127.0.0.1:8000 request made from the Squid machine got 200, at line 5. The comment in squid.conf gives the reason: local web applications "often assume that only local users can access them at 'localhost' ports."
Line 7 works the same way for link-local addresses. http://169.254.169.254/latest/meta-data/ got 403 there. The squid.conf comment says the line protects "cloud servers that provide local users with sensitive info about their server via certain well-known link-local (a.k.a. APIPA) addresses". Leave it in.
to_localhost checks the destination address, so it is not a full guard against the proxy reaching its own host. A service listening on all interfaces was reachable through Squid at the host's network address, http://10.201.13.2:8001/, and by its host name, both with 200. Those are not in 127.0.0.0/8. Blocking them is your job, and SSRF and proxies covers the destination rules that do it. One more cost of line 6: for a request that reaches it with a host name, Squid resolves the name before it decides. The trace shows can't yet compare 'to_localhost' ACL for example.com while the lookup runs.
If clients really must reach one service on the proxy host, write a narrow allow above line 6 in squid.conf. A conf.d file is too late, because the include comes after line 6.
acl local_app dst 127.0.0.1
acl local_app_port port 8000
http_access allow localnet local_app local_app_port
http_access deny to_localhostWith that in place, http://127.0.0.1:8000/ from the laptop got 200. http://localhost:8000/ still got 403 at deny to_localhost, and http://127.0.0.1:22/ got 403 at line 1, because 22 is not in Safe_ports. Ask for the address, not the name.
Finding the line that matched: debug_options 28,3#
Neither the access log nor the error page names the rule. Squid's debug output does. Debug section 28 is access control. Add this line, run squid -k reconfigure and repeat the request:
debug_options ALL,1 28,3Then search cache.log. For the laptop's request on the stock configuration, the end of the trace read (timestamps trimmed):
28,3| Acl.cc(172) matches: checked: to_linklocal = 0
28,3| Acl.cc(172) matches: checked: http_access#7 = 0
28,3| Ip.cc(538) match: aclIpMatchIp: '10.201.13.3:48436' found
28,3| Acl.cc(172) matches: checked: all = 1
28,3| Acl.cc(172) matches: checked: http_access#8 = 1
28,3| Checklist.cc(62) markFinished: 0x564ce00f48b8 answer DENIED for matchhttp_access#8 = 1 names the eighth http_access line, counted from the top with conf.d lines in their place. grep 'http_access#[0-9]* = 1' cache.log gives one line per request. for match means a rule matched; for implicit rule won means none did and rule 2 above decided. Squid 7.6 and 7.7 print the same lines with different source line numbers, such as Acl.cc(215).
The SquidAcl FAQ, which says its information is current for version 3.1, suggests debug_options ALL,1 33,2 instead. On 6.13 and 7.7, section 33 at level 2 logged connection events but no allow or deny line, so use 28. Remove the debug line when you are done: one denied request wrote dozens of lines.
Denials that are not http_access#
Some 403s look like this problem and are not.
TCP_DENIED_REPLY/403.http_reply_access deny allproduced this tag. Squid fetched the response (the log showsHIER_DIRECT), then refused to pass it on. TheX-Squid-Errorheader was the same as forhttp_access.TCP_MISS/403withX-Squid-Error: ERR_FORWARDING_DENIED 0.miss_access deny allproduced this. The request was allowed in, but Squid refused to forward it.adapted_http_access deny alllogged plainTCP_DENIED/403, identical tohttp_access. Only the 28,3 trace tells them apart.TCP_DENIED/407asks for credentials. For a Basic or NTLM 407, Chrome shows a sign-in prompt instead of an error page. The schemes are covered in proxy authentication.never_direct allow allwith no parent proxy is sometimes blamed for 403s. On 6.13 it gave a 502 withERR_READ_ERROR, loggedTCP_MISS_ABORTED/502, not a 403.
Where this answer stops applying#
The behaviour above is the packaged configuration of Squid 6.13, 6.14, 7.6 and 7.7 on 1 November 2026. Red Hat, Fedora, FreeBSD and Windows builds, and Squid 5 or older, were not tested, and upgraded installs keep whatever order they had. Intercepting setups, ssl_bump, external ACL helpers and authentication add more checks that can also refuse a request; they are not covered here. Image tags move, so check the conf.d directory of the image you actually run. Finally, allowing localnet lets in all eight private ranges it lists. If the proxy can be reached from any network you do not control, narrow localnet to your own range first. Read open proxies and misconfigured relays before you widen anything.
Frequently asked questions#
Why does Squid deny everything after a fresh install?#
Because the shipped squid.conf allows only localhost. On Debian 13, Ubuntu 24.04 and 26.04 and Alpine 3.24, http_access allow localnet is commented out, so a request from any other machine falls through to http_access deny all. Add an allow rule for your clients above that line.
I added http_access allow localnet and still get 403. Why?#
The line is probably below http_access deny all. Squid stops at the first matching rule, so a rule after deny all never runs, and squid -k parse does not warn about it. Move it above deny all, or put it in /etc/squid/conf.d/ on Debian and Ubuntu.
How do I see which http_access rule denied a request?#
Add debug_options ALL,1 28,3, reconfigure Squid and repeat the request. cache.log then shows a line such as checked: http_access#8 = 1, where 8 is the position of the matching http_access line, counting conf.d lines where they are included.
What does "the default is the opposite of the last line" mean?#
If no http_access line matches, Squid does the reverse of the last line: a list ending in deny allows the request, and a list ending in allow denies it. With no lines at all, it denies. Ending the list with http_access deny all makes the outcome explicit.
Why does Squid return 403 for CONNECT to port 8443?#
The stock rule http_access deny CONNECT !SSL_ports refuses tunnels to any port not in SSL_ports, which holds only 443. Add acl SSL_ports port 8443. That works even from a conf.d file, because it extends the existing ACL rather than adding a rule.
What does X-Squid-Error: ERR_ACCESS_DENIED 0 mean?#
It names the error page Squid sent, "Access Denied", which means an access rule refused the request. It does not say which rule: the same header appeared for deny all on both GET and CONNECT, and for http_reply_access. Use the 28,3 debug trace to find the line.
Primary sources#
Every normative claim on this page is checked against the specification or the vendor documentation listed here. Where behaviour is version dependent, the version is named in the text.
- Squid configuration directive http_access (v7 and v6)
- Squid configuration directive acl (predefined ACLs, suggested localnet)
- Squid wiki, SquidFaq/SquidAcl (rule order, AND/OR logic, debugging)
- Squid 6 release notes, section 2.4 Block to-local Traffic
- Squid source, src/cf.data.pre on the v6 branch (localhost, to_localhost, to_linklocal definitions)
- Squid configuration directive debug_options
- Ubuntu packages, squid in noble-updates (24.04)
- Ubuntu packages, squid in resolute (26.04)
- Alpine Linux packages, squid in v3.24 main
- Canonical ubuntu/squid container image
Found something wrong, or behaviour that differs on your version? Report it with the version number and a primary source. Anything substantive is fixed in the page and logged on the corrections page. See editorial standards for how pages are researched, sourced and reviewed.
More in troubleshooting proxies#
- 502 vs 503 vs 504: reading proxy errors correctly
- Cntlm vs Px: which one for an NTLM or Kerberos proxy?
- Corporate proxies and developer tooling
- Access-Control-Allow-Origin multiple values: the fix
- curl through a proxy
- Header and body size limits at the proxy
- HTTPS_PROXY: http:// or https://? Tested in 12 clients
- netsh winhttp vs Windows proxy settings: who reads which?
- nginx 413 Request Entity Too Large: find the limit
- nginx 499: what it means, and its ALB, HAProxy, Envoy names
- nginx 502 after Docker restart: fix the stale IP
- nginx in Docker: 502 on proxy_pass localhost, fixed
- nginx drops headers with underscores: why, and the fix
- nginx 400 plain HTTP request sent to HTTPS port: fix
- nginx proxy_cache always MISS: every cause, tested
- Nginx Proxy Manager 502 Bad Gateway: find your cause
- nginx connect() failed to [::1] with localhost: the fix
- nginx (13: Permission denied) to upstream: SELinux fix
- nginx SSL handshaking to upstream errors: fix each one
- nginx cuts off large downloads: find which of 5 causes
- nginx upstream sent no valid HTTP/1.0 header: the fix
- nginx worker_connections: the right number for a proxy
- The no_proxy environment variable
- POST becomes GET after a redirect: proxies and clients
- A proxy debugging playbook
- Traefik upload fails after 60 seconds: the readTimeout fix