Git 403 With the VPN Connected: Find the Responder
A 403 is a response, not a VPN verdict. Identify which component refused, then compare one variable at a time across networks and record what changed.
It works on office Wi-Fi and fails on the phone hotspot, with the VPN connected in both cases. Git returns 403 and the error is three lines long. The temptation is to treat the hotspot as the cause and change network settings until something moves. HTTP 403 means a server understood the request and refused it, so the useful question is which server, and the useful next step is a comparison that changes one thing at a time.
Read the status before the network
A 403 is not a lookup failure, a TCP timeout, or a TLS handshake that never completed. It is a decision, made by something that parsed your request.
Until you know what made that decision, every network change you try is a guess with a plausible story attached.
Who can answer with 403?
The repository application is only one candidate. A proxy, a gateway or a filtering service can sit in front of it and answer first, and the client sees the same status either way. Preserve the status, the relevant headers and a redacted error body, then correlate them with the authorised service logs. The remote URL and the endpoint actually reached can also differ through DNS or proxy configuration, so check the configured address and the process making the request.
If the refusal never reached the repository, its logs will be silent, and that silence is evidence.
Compare one variable at a time
The hotspot comparison is useful because it narrows the problem, not because it proves a cause. Two networks can differ in addressing, IP family, proxy, TLS negotiation and egress policy at the same moment.
Change one thing per attempt and record the rest. The table below is the minimum I keep in a note while doing this:
| Keep constant | Record when it changes |
|---|---|
| Repository, operation and credentials | Resolved address and IP family |
| Git process and configuration | Proxy use and the responding component |
| Intended VPN profile | Actual route and exit seen by the service |
A single row that differs between the working and the failing run is where the next hour goes.
A workaround is evidence, not an explanation
Forcing another address family can change the destination, the route or the intermediary that gets selected, so it may change the result without proving that IPv6 is incompatible with the repository. A different negotiated TLS version is an observation in the same category. Neither justifies disabling certificate checks or weakening the connection. Follow the refusing component's policy and logs to the actual decision instead of stopping at the first change that works.
A workaround you cannot explain will fail again somewhere else.
Write a report that gets fixed
Give the responder's owner the same operation, two paths, and the component that said no. Remove authorization headers, embedded credentials and private repository details before sharing anything.
The useful report is short, reproducible, and names the layer rather than the symptom.
The check I run, in order
Confirm the remote URL and the resolved endpoint, then capture the status and headers from a failing run. Repeat the identical operation on a second network and diff the two records.
Then take that diff to the component that answered:
- Proxy configuration on the client and on the network.
- Egress policy or filtering in front of the repository.
- Token or account scope, including expiry and SSO conditions.
- Repository permission for the identity the token represents.
The status semantics themselves are in RFC 9110, and they are worth quoting when you write the report.