Squid as an Authenticated Explicit Forward Proxy: JWT Validation with Centralized Credential Injection

READER BEWARE: THE FOLLOWING WRITTEN ENTIRELY BY AI WITHOUT HUMAN EDITING.

Problem Statement

Most microservice architectures treat egress as an afterthought. Individual services hold long-lived API keys or service account tokens, pass user JWTs directly to upstream APIs, and scatter credentials across dozens of environment variable files, CI pipelines, and secret stores. The result is a wide attack surface: a single compromised service can yield credentials that work indefinitely against every upstream it touches.

The architectural goal addressed in this post is centralized egress authentication and authorization through an explicit forward proxy. Concretely, the pattern:

  1. Prevents direct propagation of user JWTs to upstream APIs. The user’s short-lived token proves identity and authorizes the request at the proxy boundary—it never leaves your network perimeter.
  2. Abstracts upstream credentials behind a controlled proxy. The proxy—not individual services—holds the service account token or API key for each upstream. Clients never receive these credentials.
  3. Reduces blast radius. Rotating a leaked upstream credential requires updating one secret in one place; no application redeployment is needed.
  4. Enforces fine-grained policy. The proxy maps authenticated identities (or groups) to a permitted set of upstream destinations and HTTP methods.

The concrete request flow is:

Client
→ (Proxy-Authorization: Bearer <user_jwt>)
Squid
→ Validate JWT                        [auth helper]
→ Enforce policy                      [external ACL helper]
→ Remove user credential headers
→ Inject Authorization: Bearer <svc_credential>
→ Forward to upstream API

Architecture Overview

System Components

ComponentRole
SquidExplicit forward proxy in HTTP CONNECT / plain-proxy mode
JWT auth helperExternal basic-auth helper that decodes and verifies the Proxy-Authorization JWT
External ACL helperMaps authenticated users/groups to allowed upstream destinations
Credential storeEnvironment variables (POC) or a secrets manager (production) holding upstream service credentials
Header adaptation scriptssl_bump + request_header_replace directives / eCAP adapter to strip user headers and inject the service credential

ASCII Diagram

┌─────────────────────────────────────────────────────────────────────┐
│  Client (browser, curl, microservice)                               │
│                                                                     │
│  HTTP_PROXY=http://squid:3128                                       │
│  Proxy-Authorization: Bearer <short-lived user JWT>                 │
└───────────────────────────┬─────────────────────────────────────────┘
                            │  Explicit forward proxy request
                            ▼
┌─────────────────────────────────────────────────────────────────────┐
│  Squid (port 3128)                                                  │
│                                                                     │
│  ① proxy_auth  ──► JWT auth helper                                  │
│                     • Decode JWT header and payload                   │
│                     • Verify iss, aud, exp claims                     │
│                     • Verify RS256 signature via JWKS endpoint        │
│                     • Return "OK user=<sub>" or "ERR"                 │
│                                                                     │
│  ② external_acl ──► Policy helper                                   │
│                     • Input: user, group, dst host, dst port        │
│                     • Output: OK (permit) or ERR (deny)             │
│                     • Enforces allow-list of upstream destinations   │
│                                                                     │
│  ③ request_header_replace                                           │
│                     • Remove Proxy-Authorization                    │
│                     • Remove any Authorization forwarded by client  │
│                     • Inject Authorization: Bearer <svc_token>      │
│                       (token read from env at startup)              │
└───────────────────────────┬─────────────────────────────────────────┘
                            │  Clean request with service credential
                            ▼
              ┌─────────────────────────────┐
              │  Upstream API               │
              │  (sees only service token,  │
              │   never user JWT)           │
              └─────────────────────────────┘

Security Invariants

  • The proxy does not mint long-lived credentials from user tokens.
  • The proxy holds a secure, centrally managed upstream service credential (service account token or API key). The client never receives it.
  • User JWTs are used only for authentication and authorization at the proxy layer.
  • The proxy injects the service credential when forwarding requests to specific upstream services.

Implementation Deep Dive

1. Squid Authentication: The proxy_auth External Helper

Squid supports pluggable authentication via the basic_auth_scheme helper interface. The helper reads one line from stdin (username password) and writes OK or ERR to stdout. For JWT-based authentication, the client sends:

Proxy-Authorization: Basic base64(dummy:<jwt>)

where dummy is a placeholder username and <jwt> is the actual token. The helper ignores the username field and validates the JWT in the password field.

Why Basic encoding? Squid’s basic scheme base64-decodes the credential and splits on :. By convention the JWT goes in the password slot. Some setups use Proxy-Authorization: Bearer <token> with a custom negotiate or digest helper, but the basic-proxy-auth approach requires zero custom Squid patches and works with every HTTP client library.

JWT Auth Helper (helpers/jwt_auth.py)

#!/usr/bin/env python3
"""
Squid basic-auth helper for JWT validation.
Reads lines of "username base64(dummy:jwt)" from stdin.
Writes "OK" or "ERR" to stdout, one response per input line.

Required environment variables:
  JWT_JWKS_URL  – URL of the JWKS endpoint (RS256)
      OR
  JWT_SECRET    – HMAC secret (HS256, dev/test only)
  JWT_AUDIENCE  – expected 'aud' claim
  JWT_ISSUER    – expected 'iss' claim
"""
import sys
import os
import base64
import json
import time
import hmac
import hashlib
import struct
import urllib.request

# --------------------------------------------------------------------------- #
# Minimal JWT decode + verify (no third-party deps required in POC container) #
# --------------------------------------------------------------------------- #

def _b64pad(s: str) -> str:
    return s + "=" * (-len(s) % 4)

def _decode_part(part: str) -> dict:
    return json.loads(base64.urlsafe_b64decode(_b64pad(part)))

def _verify_hs256(header_b64: str, payload_b64: str, sig_b64: str, secret: str) -> bool:
    msg = f"{header_b64}.{payload_b64}".encode()
    expected = base64.urlsafe_b64encode(
        hmac.new(secret.encode(), msg, hashlib.sha256).digest()
    ).rstrip(b"=").decode()
    return hmac.compare_digest(expected, sig_b64)

def _fetch_jwks(url: str) -> dict:
    with urllib.request.urlopen(url, timeout=5) as r:
        return json.loads(r.read())

# Simple in-process JWKS cache (keyed by kid)
_jwks_cache: dict = {}
_jwks_fetched_at: float = 0.0
_JWKS_TTL = 300  # seconds

def _get_jwk(kid: str, jwks_url: str) -> dict | None:
    global _jwks_cache, _jwks_fetched_at
    if time.time() - _jwks_fetched_at > _JWKS_TTL:
        jwks = _fetch_jwks(jwks_url)
        _jwks_cache = {k["kid"]: k for k in jwks.get("keys", [])}
        _jwks_fetched_at = time.time()
    return _jwks_cache.get(kid)

def _verify_rs256(header_b64: str, payload_b64: str, sig_b64: str, kid: str, jwks_url: str) -> bool:
    """
    Verify an RS256 JWT using the public key fetched from the JWKS endpoint.
    Uses the cryptography package which is pre-installed in the helper image.
    """
    try:
        from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicNumbers
        from cryptography.hazmat.primitives.asymmetric.padding import PKCS1v15
        from cryptography.hazmat.primitives.hashes import SHA256
        from cryptography.exceptions import InvalidSignature
    except ImportError:
        # Fail closed: without the cryptography package, RS256 verification is impossible.
        sys.stderr.write("ERROR: cryptography package not available; rejecting RS256 token\n")
        return False

    jwk = _get_jwk(kid, jwks_url)
    if not jwk:
        return False

    def _b64int(v: str) -> int:
        return int.from_bytes(base64.urlsafe_b64decode(_b64pad(v)), "big")

    pub = RSAPublicNumbers(_b64int(jwk["e"]), _b64int(jwk["n"])).public_key()
    msg = f"{header_b64}.{payload_b64}".encode()
    sig = base64.urlsafe_b64decode(_b64pad(sig_b64))
    try:
        pub.verify(sig, msg, PKCS1v15(), SHA256())
        return True
    except InvalidSignature:
        return False

def validate_jwt(token: str) -> tuple[bool, str]:
    """
    Returns (ok: bool, username: str).
    username is the 'sub' claim on success, empty string on failure.
    """
    parts = token.split(".")
    if len(parts) != 3:
        return False, ""
    header_b64, payload_b64, sig_b64 = parts

    try:
        header = _decode_part(header_b64)
        payload = _decode_part(payload_b64)
    except Exception:
        return False, ""

    # Expiry
    if payload.get("exp", 0) < time.time():
        sys.stderr.write(f"JWT expired for sub={payload.get('sub')}\n")
        return False, ""

    # Audience
    expected_aud = os.environ.get("JWT_AUDIENCE", "squid-proxy")
    aud = payload.get("aud", "")
    if isinstance(aud, list):
        if expected_aud not in aud:
            return False, ""
    elif aud != expected_aud:
        return False, ""

    # Issuer
    expected_iss = os.environ.get("JWT_ISSUER", "")
    if expected_iss and payload.get("iss") != expected_iss:
        return False, ""

    # Signature
    alg = header.get("alg", "")
    if alg == "HS256":
        secret = os.environ.get("JWT_SECRET", "")
        if not secret:
            sys.stderr.write("JWT_SECRET not set for HS256\n")
            return False, ""
        if not _verify_hs256(header_b64, payload_b64, sig_b64, secret):
            return False, ""
    elif alg == "RS256":
        jwks_url = os.environ.get("JWT_JWKS_URL", "")
        if not jwks_url:
            sys.stderr.write("JWT_JWKS_URL not set for RS256\n")
            return False, ""
        kid = header.get("kid", "")
        if not _verify_rs256(header_b64, payload_b64, sig_b64, kid, jwks_url):
            return False, ""
    else:
        sys.stderr.write(f"Unsupported algorithm: {alg}\n")
        return False, ""

    return True, payload.get("sub", "anonymous")

def main():
    sys.stdout.reconfigure(line_buffering=True)  # critical: Squid reads line-by-line
    sys.stderr.reconfigure(line_buffering=True)  # ensure error logs appear immediately
    for line in sys.stdin:
        line = line.rstrip("\n")
        # Squid sends:  <username> <password>
        # where password = base64(dummy:jwt) decoded from the Proxy-Authorization header
        parts = line.split(" ", 1)
        if len(parts) != 2:
            sys.stdout.write("ERR\n")
            continue
        _username_placeholder, password = parts
        # password is the raw token (Squid strips the base64 for us in basic mode)
        token = password.strip()
        ok, sub = validate_jwt(token)
        if ok:
            sys.stdout.write(f"OK user={sub}\n")
        else:
            sys.stdout.write("ERR\n")

if __name__ == "__main__":
    main()

2. Policy Enforcement: The External ACL Helper

After authentication Squid calls the external ACL helper to decide whether the authenticated user may reach the requested destination. The helper receives one line per request containing the user identity and the destination host, and responds with OK or ERR.

Policy Helper (helpers/policy_acl.py)

#!/usr/bin/env python3
"""
Squid external ACL helper for destination policy enforcement.

Squid passes:  <user> <dst_host>
  (configured via external_acl_type fmt=%LOGIN %DST)

The helper reads a YAML policy file (POLICY_FILE env var) that looks like:

  rules:
    - users: ["ci-pipeline", "svc-analytics"]
      allow_destinations:
        - "api.github.com"
        - "api.datadoghq.com"
    - users: ["*"]
      allow_destinations:
        - "api.example-internal.com"

'*' in users matches any authenticated user.
"""
import sys
import os
import json

def load_policy(path: str) -> list[dict]:
    try:
        import yaml  # pyyaml installed in helper image
        with open(path) as f:
            return yaml.safe_load(f).get("rules", [])
    except ImportError:
        raise RuntimeError(
            "pyyaml is required to load the policy file. "
            "Install it with: pip install pyyaml"
        )

_policy_path = os.environ.get("POLICY_FILE", "/etc/squid/policy.yaml")
_rules: list[dict] = []

def _reload_policy():
    global _rules
    try:
        _rules = load_policy(_policy_path)
    except Exception as e:
        sys.stderr.write(f"Failed to load policy: {e}\n")
        _rules = []

_reload_policy()

def is_permitted(user: str, dst_host: str) -> bool:
    for rule in _rules:
        users = rule.get("users", [])
        if "*" in users or user in users:
            allowed = rule.get("allow_destinations", [])
            if dst_host in allowed or "*" in allowed:
                return True
    return False

def main():
    sys.stdout.reconfigure(line_buffering=True)
    for line in sys.stdin:
        line = line.rstrip("\n")
        parts = line.split(" ", 1)
        if len(parts) != 2:
            sys.stdout.write("ERR\n")
            continue
        user, dst = parts
        if is_permitted(user, dst):
            sys.stdout.write("OK\n")
        else:
            sys.stderr.write(f"DENY user={user} dst={dst}\n")
            sys.stdout.write("ERR\n")

if __name__ == "__main__":
    main()

3. Header Adaptation: Stripping User Credentials and Injecting Service Credentials

Squid’s request_header_replace and request_header_add directives let you rewrite outbound headers without any external ICAP/eCAP server—making the POC simpler.

The configuration:

  1. Removes the Proxy-Authorization header (Squid already strips this by default for HTTP; for HTTPS CONNECT you must be explicit).
  2. Removes any Authorization header the client may have added (preventing credential forwarding).
  3. Injects a fixed Authorization header read from an environment variable at Squid startup.

Because Squid’s squid.conf does not natively interpolate environment variables into request_header_replace values, a small entrypoint script generates the final squid.conf from a template at container start.


Proof-of-Concept Setup

Directory Layout

squid-jwt-proxy/
├── docker-compose.yaml
├── squid/
│   ├── Dockerfile
│   ├── squid.conf.tmpl          # Template; entrypoint fills in env vars
│   ├── entrypoint.sh
│   └── policy.yaml
├── helpers/
│   ├── jwt_auth.py
│   └── policy_acl.py
├── token-issuer/
│   ├── Dockerfile
│   ├── main.py                  # FastAPI: /token + /.well-known/jwks.json
│   ├── issuer_private_key.pem   # generated locally; never committed
│   └── issuer_public_key.pem
└── scripts/
    └── test_proxy.sh

Generating RSA Keys

mkdir -p squid-jwt-proxy/token-issuer
openssl genrsa -out squid-jwt-proxy/token-issuer/issuer_private_key.pem 2048
openssl rsa -in squid-jwt-proxy/token-issuer/issuer_private_key.pem \
    -pubout -out squid-jwt-proxy/token-issuer/issuer_public_key.pem

docker-compose.yaml

version: "3.9"

services:
  # ── Token issuer ─────────────────────────────────────────────────────────
  token-issuer:
    build: ./token-issuer
    ports:
      - "8080:8080"
    environment:
      PRIVATE_KEY_PATH: /app/issuer_private_key.pem
      PUBLIC_KEY_PATH:  /app/issuer_public_key.pem

  # ── Squid explicit forward proxy ─────────────────────────────────────────
  squid:
    build: ./squid
    ports:
      - "3128:3128"
    environment:
      # JWT validation
      JWT_JWKS_URL:  "http://token-issuer:8080/.well-known/jwks.json"
      JWT_AUDIENCE:  "squid-proxy"
      JWT_ISSUER:    "http://token-issuer:8080"
      # Upstream service credential injected by the proxy
      UPSTREAM_SVC_TOKEN: "svc-account-token-abc123"   # replace with real token
      # Policy file path (mounted via volume)
      POLICY_FILE:   "/etc/squid/policy.yaml"
    volumes:
      - ./squid/policy.yaml:/etc/squid/policy.yaml:ro
      - ./helpers:/usr/local/lib/squid/helpers:ro
    depends_on:
      - token-issuer

  # ── Echo server (stands in for the real upstream API) ────────────────────
  echo-server:
    image: ealen/echo-server:latest
    ports:
      - "3000:80"

squid/Dockerfile

FROM ubuntu:22.04

RUN apt-get update && apt-get install -y --no-install-recommends \
    squid \
    python3 \
    python3-pip \
    gettext-base \
    && pip3 install --no-cache-dir cryptography pyyaml \
    && rm -rf /var/lib/apt/lists/*

COPY entrypoint.sh /entrypoint.sh
COPY squid.conf.tmpl /etc/squid/squid.conf.tmpl
RUN chmod +x /entrypoint.sh

EXPOSE 3128
ENTRYPOINT ["/entrypoint.sh"]

squid/entrypoint.sh

#!/bin/bash
set -euo pipefail

# Render squid.conf from template, substituting environment variables
envsubst < /etc/squid/squid.conf.tmpl > /etc/squid/squid.conf

# Ensure helper scripts are executable
chmod +x /usr/local/lib/squid/helpers/*.py

# Start Squid in the foreground
exec squid -N -f /etc/squid/squid.conf

squid/squid.conf.tmpl

# ── Ports ────────────────────────────────────────────────────────────────────
http_port 3128

# ── Access log ───────────────────────────────────────────────────────────────
access_log stdio:/dev/stdout
cache_log  stdio:/dev/stderr
cache_store_log none
cache deny all

# ── Authentication helper ─────────────────────────────────────────────────────
# Reads lines of "<placeholder> <jwt>" from stdin; writes "OK user=<sub>" or "ERR"
auth_param basic program /usr/bin/python3 /usr/local/lib/squid/helpers/jwt_auth.py
auth_param basic children 5 startup=1 idle=1
auth_param basic realm "JWT Proxy"
auth_param basic credentialsttl 2 minutes

# ── External ACL helper ───────────────────────────────────────────────────────
# %LOGIN = authenticated username, %DST = destination host
external_acl_type jwt_policy \
    children=3 \
    %LOGIN %DST \
    /usr/bin/python3 /usr/local/lib/squid/helpers/policy_acl.py

# ── ACL definitions ──────────────────────────────────────────────────────────
acl authenticated  proxy_auth REQUIRED
acl policy_permit  external   jwt_policy
acl CONNECT        method     CONNECT
acl SSL_ports      port       443
acl Safe_ports     port       80 443 8080

# ── Access rules ─────────────────────────────────────────────────────────────
# Deny requests to unsafe ports
http_access deny !Safe_ports
http_access deny CONNECT !SSL_ports

# Require authentication
http_access deny !authenticated

# Require policy approval
http_access deny !policy_permit

# Allow everything that passed auth + policy
http_access allow authenticated policy_permit

# Default deny
http_access deny all

# ── Header rewriting ─────────────────────────────────────────────────────────
# Strip any Authorization header forwarded by the client
request_header_access Authorization deny all

# Inject the centrally managed service credential
# UPSTREAM_SVC_TOKEN is substituted at container start by envsubst
request_header_add Authorization "Bearer ${UPSTREAM_SVC_TOKEN}" all

# Squid automatically removes Proxy-Authorization before forwarding;
# the following makes that explicit and ensures it is always removed.
request_header_access Proxy-Authorization deny all

Note on request_header_add and all: In Squid ≥ 4.x, request_header_add with the all argument adds the header to every outbound request after the ACL chain has permitted it. Combined with request_header_access Authorization deny all (which strips any client-supplied Authorization), this guarantees that the upstream only ever sees the proxy’s own service credential.

squid/policy.yaml

rules:
  # CI pipelines may reach GitHub API and Datadog
  - users:
      - "ci-pipeline"
      - "ci-staging"
    allow_destinations:
      - "api.github.com"
      - "api.datadoghq.com"

  # Analytics service may reach Snowflake and internal data APIs
  - users:
      - "svc-analytics"
    allow_destinations:
      - "account.snowflakecomputing.com"
      - "data-api.internal.example.com"

  # Any authenticated user may reach the echo server (POC only)
  - users:
      - "*"
    allow_destinations:
      - "echo-server"

Token Issuer (token-issuer/Dockerfile)

FROM python:3.12-slim

WORKDIR /app
RUN pip install --no-cache-dir fastapi uvicorn[standard] PyJWT cryptography

COPY main.py .
COPY issuer_private_key.pem .
COPY issuer_public_key.pem .

EXPOSE 8080
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"]

Token Issuer (token-issuer/main.py)

import os
import base64
import datetime

import jwt
from fastapi import FastAPI, Query
from cryptography.hazmat.primitives.serialization import (
    load_pem_private_key, load_pem_public_key
)

app = FastAPI()

_priv = load_pem_private_key(
    open(os.environ["PRIVATE_KEY_PATH"], "rb").read(), password=None
)
_pub  = load_pem_public_key(
    open(os.environ["PUBLIC_KEY_PATH"], "rb").read()
)
_pub_numbers = _pub.public_numbers()

def _b64url(n: int, length: int) -> str:
    return base64.urlsafe_b64encode(
        n.to_bytes(length, "big")
    ).rstrip(b"=").decode()

@app.get("/.well-known/jwks.json")
def jwks():
    return {
        "keys": [{
            "kty": "RSA",
            "use": "sig",
            "alg": "RS256",
            "kid": "poc-v1",
            "n": _b64url(_pub_numbers.n, 256),
            "e": _b64url(_pub_numbers.e, 3),
        }]
    }

@app.post("/token")
def issue_token(
    sub: str = Query(..., description="Subject (service or user identity)"),
    ttl: int = Query(900, description="Token lifetime in seconds"),
):
    now = datetime.datetime.now(datetime.UTC)
    payload = {
        "iss": "http://token-issuer:8080",
        "sub": sub,
        "aud": "squid-proxy",
        "iat": now,
        "exp": now + datetime.timedelta(seconds=ttl),
    }
    token = jwt.encode(payload, _priv, algorithm="RS256", headers={"kid": "poc-v1"})
    return {"access_token": token, "expires_in": ttl, "sub": sub}

Running the POC

1. Start the Stack

cd squid-jwt-proxy
docker compose up --build -d

2. Obtain a Short-Lived JWT

# Issue a 15-minute token for the ci-pipeline identity
TOKEN=$(curl -s -X POST \
  "http://localhost:8080/token?sub=ci-pipeline&ttl=900" \
  | python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])")

echo "JWT: ${TOKEN:0:60}..."

3. Send a Request Through the Proxy

Squid’s basic-auth scheme expects Proxy-Authorization: Basic base64(placeholder:<jwt>). Most HTTP clients accept a proxy URL of the form http://user:password@host:port, so:

# curl encodes the credentials automatically
curl -v \
  -x "http://placeholder:${TOKEN}@localhost:3128" \
  "http://echo-server/anything"

4. Verify Header Injection

The echo server mirrors all request headers it received. In the response JSON look for the Authorization header—it should show the proxy’s service token, not the user JWT:

{
  "request": {
    "headers": {
      "Authorization": "Bearer svc-account-token-abc123",
      "Host": "echo-server",
      ...
    }
  }
}

The Proxy-Authorization header will be absent—Squid consumed it for authentication and the request_header_access Proxy-Authorization deny all directive ensures it is stripped before forwarding.

5. Verify Policy Enforcement

# Attempt to reach a destination not in the ci-pipeline allow-list
curl -v \
  -x "http://placeholder:${TOKEN}@localhost:3128" \
  "http://account.snowflakecomputing.com/session/v1/login-request" \
  2>&1 | grep -E "HTTP|< "

Squid returns 403 Forbidden because account.snowflakecomputing.com is not in the ci-pipeline destination allow-list.

6. Verify Rejection of Expired/Invalid Tokens

# Craft an obviously invalid token
curl -v \
  -x "http://placeholder:not-a-jwt@localhost:3128" \
  "http://echo-server/anything" \
  2>&1 | grep "407"

Squid responds with 407 Proxy Authentication Required when the helper returns ERR.


How the Credential Injection Works End-to-End

1. Client sets HTTP_PROXY=http://localhost:3128
   and sends:
     GET http://echo-server/anything HTTP/1.1
     Proxy-Authorization: Basic cGxhY2Vob2xkZXI6ZXlKaGJHY2lP...
       (base64 of "placeholder:<jwt>")

2. Squid intercepts the request and calls jwt_auth.py with:
     placeholder eyJhbGciOiJSUzI1NiIsImtpZCI6InBvYy12MSJ9...

3. jwt_auth.py:
   a. Splits on first space → token = eyJhbGci...
   b. Decodes header: {"alg":"RS256","kid":"poc-v1"}
   c. Decodes payload: {"iss":"http://token-issuer:8080","sub":"ci-pipeline",
                        "aud":"squid-proxy","exp":1740623647}
   d. Checks exp > now  ✓
   e. Checks aud == "squid-proxy"  ✓
   f. Fetches JWKS from http://token-issuer:8080/.well-known/jwks.json
      (cached for 300 s)
   g. Verifies RS256 signature  ✓
   h. Writes: OK user=ci-pipeline

4. Squid marks the request as authenticated user "ci-pipeline".
   It calls policy_acl.py with:
     ci-pipeline echo-server

5. policy_acl.py:
   a. Loads policy.yaml
   b. Finds rule: users=["*"] → allow_destinations=["echo-server"]
   c. Writes: OK

6. Squid applies header rewriting:
   - Strips Proxy-Authorization  (already consumed; made explicit)
   - Strips any Authorization from the client
   - Adds Authorization: Bearer svc-account-token-abc123

7. Squid forwards:
     GET /anything HTTP/1.1
     Host: echo-server
     Authorization: Bearer svc-account-token-abc123
     (no Proxy-Authorization, no user JWT)

8. echo-server responds; Squid relays response to client.

Security Considerations

Token Lifetime

Caller TypeRecommended TTLRationale
CI/CD pipeline step15 minutesIssued at step start; fail fast on expiry
Long-running service1 hourRefresh via a sidecar before expiry
Interactive developer8 hoursFull workday; revocable via JWKS key rotation

Upstream Credential Rotation

Because clients never hold the service credential:

  1. Update the credential in your secrets manager (AWS Secrets Manager, HashiCorp Vault, etc.).
  2. Inject the new value into the Squid container via a rolling restart or a secrets sync sidecar.
  3. No changes are required for any client.

Scope / Destination Granularity

The policy helper demonstrated here maps user identities to allowed hostnames. For production, extend policy.yaml to also enforce HTTP method:

rules:
  - users: ["ci-pipeline"]
    allow:
      - host: "api.github.com"
        methods: ["GET"]
      - host: "api.datadoghq.com"
        methods: ["GET", "POST"]

The external ACL helper receives the method via %METHOD in the external_acl_type fmt string.

TLS Interception Considerations

The POC targets HTTP (non-TLS) destinations, which allows Squid to read and rewrite headers directly. For HTTPS destinations in production:

  • Option A (Explicit proxy with CONNECT tunneling): Squid tunnels the encrypted stream transparently. It cannot rewrite headers inside the TLS tunnel, so header injection only works for HTTP. Many internal service-to-service calls use plain HTTP inside the cluster network, making this option viable.
  • Option B (SSL bump / TLS interception): Squid terminates TLS, rewrites headers, and re-encrypts. Requires deploying a trusted internal CA to all clients. Suitable for controlling egress in a zero-trust internal network where you control all endpoints.
  • Option C (mTLS at the application layer): The upstream accepts the service credential over mTLS. The proxy injects a client certificate rather than an Authorization header. Squid supports ssl_bump with client certificate configuration.

For most internal microservice egress use cases, plain HTTP within the cluster (with TLS at the service-mesh layer via mutual TLS) is the simplest starting point.

Audit Logging

Enable structured Squid access logs and ship them to your SIEM. Each log entry includes:

  • Authenticated username (%[un] / %usr)
  • Destination URL
  • HTTP status
  • Request size

Combined with the policy helper’s DENY log lines, this provides a full audit trail: who requested access to which upstream, whether it was permitted, and when.

Preventing Credential Leakage via X-Forwarded Headers

Clients may attempt to inject upstream credentials via custom headers. Extend the request_header_access directives:

request_header_access X-Api-Key deny all
request_header_access X-Auth-Token deny all
request_header_access Cookie deny all

Whitelist only headers your upstream services legitimately need.


Production Hardening Checklist

  • Replace UPSTREAM_SVC_TOKEN with a reference to AWS Secrets Manager or HashiCorp Vault; inject via an init container or secrets sync sidecar.
  • Use RS256 JWTs signed by your internal IdP (Keycloak, Okta, etc.) rather than a self-hosted token issuer.
  • Enable Squid’s via and forwarded_for controls to prevent IP leakage:
    forwarded_for delete
    via off
    
  • Run the Squid container as a non-root user with read-only filesystem and drop all Linux capabilities except NET_BIND_SERVICE.
  • Put the proxy behind a network policy that allows only approved source namespaces/CIDRs to connect on port 3128.
  • Set auth_param basic credentialsttl to a value shorter than the maximum JWT TTL to force re-authentication on expiry.
  • Add Prometheus metrics scraping via Squid’s logformat or a sidecar exporter, and alert on authentication failure rates.
  • Regularly rotate the JWKS signing key; JWKS caching in the helper ensures seamless transition when both old and new keys are present in the keys array simultaneously.

Summary

Squid’s external helper interface and header rewriting directives provide everything needed to implement a JWT-authenticated forward proxy that injects centrally managed service credentials—without any additional ICAP/eCAP server.

The key takeaways are:

  • User JWTs never leave the proxy. They are consumed by the auth helper and discarded. The upstream API sees only the service credential held by the proxy.
  • Upstream credentials are centralized. Rotating them requires no changes to any client application.
  • Policy is enforced at the proxy boundary. The external ACL helper can enforce fine-grained rules mapping identities to permitted destinations, methods, and time windows.
  • The POC is reproducible with three docker compose services. Swap the echo server for a real upstream and the self-signed token issuer for your IdP JWKS endpoint when moving to production.

This pattern—short-lived JWT in, service credential out—is the forward-proxy analogue of the reverse-proxy credential injection pattern provided by Ory Oathkeeper. It is particularly well-suited to environments where clients cannot be modified to use a reverse proxy URL, or where a single centralized egress point must serve heterogeneous clients (browsers, CLIs, microservices, and CI pipelines) without per-client credential distribution.