Users disconnect the VPN, hit a 403 on an internal site, and report that the proxy is broken. Three separate things can produce that page: the origin refused the request, the forward proxy refused to open the tunnel, or the proxy demanded its own credentials. Each of them is a correct answer to a different question, and flattening them into one generic failure page destroys the only evidence you have. Sort the refusal by the component that made it, then compare the network path each component actually uses.

Name the component that produced the 403

An HTTP status code belongs to the party that sent it. When the origin returns 403, it parsed the request and decided the caller may not have the resource, which is an authorization answer rather than a transport problem. When a forward proxy answers 403 to a CONNECT, it refused to build the tunnel at all, and nothing upstream was ever contacted. When it answers 407, it wants its own credentials, which have nothing to do with the application login.

What you observeWhat it means
Origin replies 403The origin understood the request and denied it
Proxy refuses CONNECT with 403The proxy declined to open that tunnel
Proxy replies 407Proxy authentication is required first
Gateway replies 502 or 504Upstream failure or timeout, not a permission decision

RFC 9110 defines these roles, including the proxy-specific challenge. Read the response's own status and any Proxy-Authenticate header before reading the body, because a friendly HTML error page usually hides which component wrote it.

What a CONNECT tunnel hides from a byte relay

Once a proxy returns 200 to CONNECT, it is relaying bytes and the browser keeps its own TLS session with the origin. A byte-relaying proxy therefore cannot read the encrypted 403 inside that session, and it never becomes the author of the denial. A refusal before the tunnel opens is a completely different event, produced one layer lower.

A TLS-terminating reverse proxy is in a different position: it owns the TLS boundary, parses HTTP, and can legitimately rewrite or intercept responses. That ability is a configuration choice, not a property of proxying in general, and it does not authorize bypassing the application's access policy. Reading the response is a capability; deciding what the caller may see remains the origin's job.

The practical consequence is that you must know which of the three shapes you run before you interpret a 403. Adding HTTP inspection rules to a stream proxy does not give it visibility, and expecting a byte relay to explain an origin denial will send you looking in the wrong log.

Compare the proxy's egress path with the browser's tunnel address

A browser connected to a VPN does not automatically move a separate proxy server onto that tunnel. The origin sees the proxy's egress address, and if the proxy still leaves through the local default route, the protected site sees an address it was never configured to trust. The inner VPN address on the laptop is invisible to that origin. This is the single most common reason a working tunnel still yields a 403 from a site that behaves correctly for everyone else.

Test the two paths separately so the addresses cannot be confused.

curl -sS -o /dev/null -w 'status=%{http_code}\n' --noproxy '*' https://198.51.100.20/private
curl -sS -o /dev/null -w 'status=%{http_code}\n' -x http://203.0.113.10:3128 https://198.51.100.20/private

The first request shows what the origin answers for your own address, the second shows what it answers for the proxy's. A matching pair of 403s points at the origin's authorization rules; a 200 followed by a 403 points at the proxy path. Ask the origin owner which source addresses the rule set actually admits rather than assuming the VPN pool is included.

Test each boundary with a request that isolates it

Isolate the proxy layer by asking it for something it cannot refuse for application reasons. A CONNECT to a host the proxy is allowed to reach, with output discarded, tells you whether tunnel admission works at all.

curl -sSv -o /dev/null -x http://203.0.113.10:3128 https://198.51.100.20/ 2>&1 | grep -E 'CONNECT|HTTP/1.1|Proxy-Authenticate'

If that prints a 200 for the CONNECT and then a TLS handshake, proxy admission is fine and the later 403 came from the origin. If it prints 407, supply proxy credentials with --proxy-user and repeat before drawing any conclusion about the site. If it prints 403 on the CONNECT line, the proxy's own allow-list is the boundary to examine, and the application is not involved.

Keep credentials out of shell history and out of pasted transcripts. A proxy password typed into a command line is visible to other local users, and a captured CONNECT trace with credentials is worse than no trace at all.

Keep the verdict readable instead of rewriting the page

Once you know which component refused, preserve that answer. A reverse proxy that relabels an origin 403 as a 504 timeout, or replaces it with a success page, hides an authorization decision behind an availability story and sends the next responder to the wrong team. Custom error pages are fine when they keep the status and add context.

Caching deserves the same care. RFC 9111 governs how shared caches may reuse responses, and a denial produced for one caller must not be handed to another as if it were permission. If denial responses are stored at all, keep them private and scoped, and never add a public freshness rule just to reduce upstream traffic.

Finish with a written verdict that names the component, the status it produced, and the source address the origin saw. Record the exact request, the proxy settings in force, and the time, so the next report can be matched against the same evidence instead of re-derived from memory. That record answers the next report in seconds and stops the same 403 from being re-diagnosed as a VPN outage.

Inyandiko zihuye