Writing your first PAC file: a practical cookbook
This is the constructive companion to PAC file mistakes that break user traffic. That post is for when things are already on fire; this one is for building the file correctly in the first place — what each piece does, which routing destination to pick and when, and the patterns worth stealing, each as a short recipe: situation → snippet → why this way.
the anatomy of a PAC file
A PAC (proxy auto-configuration) file is a single JavaScript function with a contract: the browser hands it a URL and a hostname, and it hands back routing instructions — for every single request.
function FindProxyForURL(url, host) {
// your rules, top to bottom, first match wins
return "DIRECT";
}
The return value is where beginners get surprised: it's not one
destination, it's a failover chain. A return like
"PROXY proxy-a.example:80; PROXY proxy-b.example:80;
DIRECT" means: try proxy A; if it's unreachable, try proxy
B; if both fail, go direct. Every return statement you write is
also a statement about what happens when things break — which is
why the fallback
mistakes in the companion post matter so much.
the four destinations (and when to use each)
Almost every rule you'll ever write returns one of four kinds of answer:
-
DIRECT— skip all proxies, straight to the destination. For traffic that must not be intercepted (certificate-pinned apps, some identity providers), internal destinations when you're on the internal network, and anything you've deliberately decided not to inspect. -
An enterprise proxy or data center —
"PROXY proxy.corp.example:9400". The classic on-premises path: traffic needs your organization's egress, policy, or logging. -
A local listening proxy on the workstation —
"PROXY 127.0.0.1:9000". Used when an endpoint agent runs a listener on the machine itself and takes the traffic from there; the PAC's job is just to hand traffic to the agent. (Port varies by product.) - A cloud security edge — for SSE platforms, the nearest cloud node. With Zscaler, you don't hardcode node addresses; you use the platform's PAC variables and let the service pick the closest one (next recipe).
The decision is architectural, not syntactic: who needs to see this traffic, and where should it egress? Write that answer down per traffic class before writing any rules — the PAC file is just the transcription of that decision.
recipe: matching traffic
Situation: you need rules to fire on the right requests — by hostname, domain, or URL.
// normalize once at the top — matching is case-sensitive
host = host.toLowerCase();
// wildcard hostname match — the workhorse
if (shExpMatch(host, "*.corp.example")) return "DIRECT";
// exact domain and its subdomains
if (dnsDomainIs(host, ".partner-site.com")) return "PROXY proxy.corp.example:9400";
// unqualified names like http://intranet — internal by definition
if (isPlainHostName(host)) return "DIRECT";
// match on the full URL when scheme or path matters
if (shExpMatch(url, "ftp:*")) return "PROXY proxy.corp.example:9400";
Why this way: all of these are string comparisons —
instant, no network dependency. That's also why the
toLowerCase() line exists: it's not a PAC function,
just plain JavaScript lowercasing the hostname once before any
rules run. String comparison is case-sensitive —
shExpMatch(host, "*.corp.example") silently fails to
match INTRANET.Corp.Example — so normalizing up top
lets every rule below be written in lowercase and trust its
matches. Modern browsers already lowercase hostnames during URL
parsing, but
Zscaler's
best-practices guide notes that some browsers may execute PAC
files case-sensitively and recommends exactly this conversion, and
Cloudflare
agrees — so treat it as one line of cheap insurance.
The functions to be suspicious of
are the ones that trigger live DNS lookups
(dnsResolve(), isInNet(),
isResolvable()); both
Zscaler
and
Microsoft
flag them as the top cause of PAC-related hangs. The full story is
in mistake 4 of the
companion post. If you truly need an IP check,
Cloudflare's
guidance is sensible: resolve once into a variable and reuse
it, and keep those rules low in the file.
recipe: OR-logic — one decision, many matches
Situation: several different destinations should all get the same treatment, and you don't want five separate if-statements repeating the same return.
// one decision, many ways to qualify for it
if (shExpMatch(host, "*.corp.example") ||
shExpMatch(host, "*.corp-legacy.example") ||
dnsDomainIs(host, ".internal-partner.com") ||
isPlainHostName(host)) {
return "DIRECT";
}
Why this way: || is JavaScript's OR —
the block fires if any condition matches, and evaluation
stops at the first true condition, so put the most common match
first. Grouping related exceptions into one block is also a Zscaler
best practice: it keeps the file readable, and readable files are
the ones that don't grow
shadowed rules.
recipe: geo-aware routing
Situation: a user in one country needs a site that's geo-restricted to another — the classic example being a US user who needs a foreign government portal. Instead of VPN gymnastics, route just that traffic out through your platform's data center in the right country.
// government sites in India egress via the in-country node;
// falls back to the nearest node if none exists in-country
if (shExpMatch(host, "*.gov.in")) {
return "PROXY ${COUNTRY_GATEWAY}:80; PROXY ${GATEWAY}:80";
}
// default: nearest cloud edge to the user, wherever they are
return "PROXY ${GATEWAY}:80; PROXY ${SECONDARY_GATEWAY}:80; DIRECT";
Why this way: those ${...} tokens are
Zscaler PAC variables, resolved by the platform when the file is
served: ${GATEWAY} and
${SECONDARY_GATEWAY} become the closest service edge
to the user (never hardcode node addresses — the variables are what
make failover work), and per
Zscaler's
location-based forwarding doc,
${COUNTRY_GATEWAY} returns the closest node
within the user's country, falling back to
${GATEWAY} behavior if there isn't one. Other SSE
vendors expose similar mechanisms; the pattern — match the
destination, choose the egress geography — is portable.
recipe: ordering rules for performance
Situation: the file works, but you want it fast for every user, on every request — because it runs on every request.
The order that serves most files well:
- Normalize (
host = host.toLowerCase();) — once, at the top. - Cheap, high-probability checks first: plain hostnames, your internal wildcard domains — the requests that make up most of the traffic should exit the file in one or two comparisons.
- Specific exceptions next: pinned apps, identity providers, geo rules.
- Expensive checks (any IP-based logic) as low as possible, so few requests ever reach them.
- The deliberate fallback last.
Why this way: this is Zscaler's published guidance almost verbatim — simple exceptions first, high-probability checks near the top, minimal regex — and it compounds with everything above: top-down evaluation means order is both a correctness tool and a performance tool.
recipe: ending the file on purpose
Situation: the last line of the file — the one that decides the fate of everything no other rule matched.
// production: unmatched traffic rides the cloud edge,
// with DIRECT as a last-resort failover if all nodes are unreachable
return "PROXY ${GATEWAY}:80; PROXY ${SECONDARY_GATEWAY}:80; DIRECT";
// what leaks out of test files into production:
return "DIRECT";
Why this way: the trailing DIRECT in
the first version is a failover decision — "if the entire
proxy platform is unreachable, fail open so users can work." Some
organizations would rather fail closed; that's a policy choice you
should make consciously. What you never want is the second version
arriving by copy-paste — the full incident writeup is
mistake 3 in the
companion post.
putting it together: a full annotated PAC file
Every recipe above, assembled in the order the previous section prescribes. This is a skeleton to adapt, not to deploy blind — your hostnames, ports, and fail-open/fail-closed stance will differ.
function FindProxyForURL(url, host) {
// 0. normalize once
host = host.toLowerCase();
// 1. internal traffic stays internal (highest volume first)
if (isPlainHostName(host) ||
shExpMatch(host, "*.corp.example")) {
return "DIRECT";
}
// 2. traffic that must not be intercepted
if (shExpMatch(host, "login.idp.example") ||
shExpMatch(host, "*.pinned-app.example")) {
return "DIRECT";
}
// 3. geo exception: in-country egress for foreign gov sites
if (shExpMatch(host, "*.gov.in")) {
return "PROXY ${COUNTRY_GATEWAY}:80; PROXY ${GATEWAY}:80";
}
// 4. everything else: nearest cloud edge, deliberate fail-open
return "PROXY ${GATEWAY}:80; PROXY ${SECONDARY_GATEWAY}:80; DIRECT";
}
Before this goes anywhere near production: test it against the URLs that matter, keep a golden copy of the working version, and know exactly which users get this file and how it reaches them.
test it before your users do
The PAC File Tester is live on this site: paste your draft PAC file and a test URL, and see exactly which rule matches and what gets returned — plus a linter for shadowed rules, over-broad wildcards, risky fallbacks, and DNS-lookup traps. Runs entirely in your browser; your PAC file never leaves your machine. Subscribe and the one-page PAC cheat sheet is yours as soon as you confirm:
ack — check your inbox for a confirmation link.
One confirmation email, then you're in. Unsubscribe anytime.