DEV Community

Soham Mondal
Soham Mondal

Posted on Originally published at sohammondal.com

How to Secure a Staging Environment With Google Cloud IAP

Every staging environment starts out reachable by anyone with the URL, and every team eventually notices that's a problem — a crawler indexes it, a support ticket links to it in public, an ex-employee's bookmark still works. The fix people reach for first is usually one of two extremes: a VPN nobody wants to run, or a login screen nobody wants to maintain. On Google Cloud there's a narrower option that costs almost nothing to turn on: Identity-Aware Proxy (IAP), a toggle on the load balancer that requires a verified Google identity before any request reaches the backend at all.

Point a browser at a protected URL, IAP bounces it through a Google login once, and a session cookie carries it for the rest of the day. For a browser-facing staging environment or internal tool, that's most of what "secure it" or "limit access to it" ends up meaning in practice — no VPN client to distribute, no user database to run, access that IAM already tracks. That ease is specific to browsers, though. The moment the client isn't one — a mobile app's embedded WebView, a CI job, a script hitting the same backend — IAP's contract changes completely, and it's worth knowing that cost before leaning on IAP for more than the browser case.

A browser redirected from a protected URL into Google's IAP sign-in wall

What IAP actually is

IAP (Identity-Aware Proxy)
A Google Cloud layer in front of a load balancer's backend service that requires a verified Google identity before any request reaches the backend. Toggled per backend service, not per URL path.

Mechanically, it's an auth check bolted onto GCLB (Google Cloud Load Balancer): a request hits the load balancer, IAP checks for a valid identity — a session cookie from a prior OAuth flow, or a bearer token with the right audience — and either lets the request through or bounces it into a Google sign-in flow. For a browser, that flow is a redirect and a cookie. For anything else, there's no redirect to follow and no cookie to hold.

Why teams reach for it

The appeal isn't the auth mechanism itself — OAuth redirects and session cookies are nothing new. It's what you don't have to build:

  • Staging and internal tools stay off the public internet without standing up a VPN or writing your own login layer.
  • Access rides on identities your org already has (Google Workspace), not a separate user database.
  • It's a toggle on an existing backend service, not another service to run, patch, and keep available.
  • Access is a list in IAM, not a config buried in application code — add or remove a person there, and it takes effect immediately, everywhere that backend is used.
  • Every access decision lands in Cloud Audit Logs, so "who hit staging and when" is a query, not an archaeology project.

For a browser-facing surface, that's close to free. The tax shows up somewhere else entirely.

Setting up IAP to limit access to staging

1. Enable IAP on a backend service. In the GCP console, under the load balancer's backend service, flip the IAP toggle. This is per backend service, not per URL path — anything routed to that backend is now gated.

The IAP toggle enabled for a single GCLB backend service in the GCP console

2. Grant access. Under IAM, add the principals (individual users, a Google group, or a service account) that should get through, with the IAP-Secured Web App User role. Nothing else grants access — being a project owner doesn't bypass IAP.

A principal granted the IAP-Secured Web App User role in IAM

3. Verify the browser flow. Hit the protected URL in a browser as a granted principal — expect the Google sign-in redirect once, then normal access.

4. Get a token for a non-browser client. For a quick manual test, gcloud auth print-identity-token --audiences=<IAP_CLIENT_ID> mints a token you can attach directly. In an app, that's a service-account-signed JWT or an OIDC client credential exchange instead, but the shape of the request is the same either way:

# Without a token: IAP redirects to the login page
curl -i https://stage.example.com/

# With a correctly-audienced bearer token: passes straight through
curl -i -H "Authorization: Bearer $(gcloud auth print-identity-token --audiences=$IAP_CLIENT_ID)" \
  https://stage.example.com/
Enter fullscreen mode Exit fullscreen mode

If a browser is the only client that needs through, that's the whole setup. If a mobile app, a CI job, or a script also needs to reach the same backend, the next section is the real cost of that — worth reading before assuming IAP covers those the same way it covers a browser tab.

Where it stops being invisible: web vs. non-browser clients

On the web, the free lunch is real: no client code, no token management, a cookie the browser already knows how to carry. The one thing worth knowing is that it's still an interactive flow — anything that can't render a redirect and complete a Google login (headless browsers, most testing tools) hits the same wall a non-browser client does.

Off the web, none of that machinery exists. IAP doesn't issue a session cookie for token-based auth — there's nothing to persist, nothing to keep warm. A non-browser client has to attach a bearer ID token to every request it wants to get through, and that's where the actual complexity of "add IAP" lives. Four things commonly go wrong, in roughly the order teams find them:

The wrong header wins. Proxy-Authorization is the technically correct header for talking to an intermediary like IAP — it's meant to be stripped before the request reaches your own backend. In practice, GCLB's IAP implementation doesn't reliably honor it, and on Android, WebViews reject it outright on document requests (ERR_INVALID_ARGUMENT). What actually works is Authorization — the same header slot most apps already use for their own session. That's an awkward collision, not an elegant design; it's just what's left once the "correct" option turns out not to work reliably.

// Attach the IAP token only to the initial document load
const source = {
  uri: stageUrl,
  headers: iapToken ? { Authorization: `Bearer ${iapToken}` } : undefined,
};
Enter fullscreen mode Exit fullscreen mode

The token has the wrong audience. A client library's default sign-in call often hands back an ID token scoped to itself — "this is the mobile app" — not to IAP. Send that one and IAP rejects it with an audience mismatch, even though the token is valid, signed, and unexpired. The token that clears IAP has to be requested separately and checked before it's trusted:

private async requestAndCacheToken(): Promise<boolean> {
  try {
    this.token = await this.requestToken(); // the IAP-audience token, not the default sign-in one
    if (this.token && decodeAud(this.token) !== IAP_CLIENT_ID) {
      // cached session, wrong audience — force re-auth instead of trusting it
      await this.reauthenticate();
      return this.requestAndCacheToken();
    }
    this.notify(this.token);
    return Boolean(this.token);
  } catch (error) {
    captureException('[IAPAuthClient] token fetch failed', error);
    this.notify(null);
    return false;
  }
}
Enter fullscreen mode Exit fullscreen mode

aud vs. azp
Both show up in an OIDC token and look interchangeable until a request gets rejected. aud is who the token is for — what IAP checks against its allowlisted clients. azp is who actually requested it — the client's own identity. Mixing them up produces a token that's completely valid and still gets bounced.

Everything the header doesn't reach still goes through IAP. A bearer token on the document request only covers that one request. Every automatic follow-up request a page makes — <script src>, stylesheets, fonts — hits the same IAP-protected backend with no header attached, gets redirected to the Google login page, and comes back as HTML. Browsers then hit X-Content-Type-Options: nosniff, refuse to execute HTML as JavaScript, and the page just... stops. No error, no crash, just a static shell that never hydrates.

X-Content-Type-Options: nosniff
Tells the browser not to guess a resource's type from its content. When IAP swaps a JS file for an HTML login page, this header is what stops the browser from quietly trying to run HTML as a script — it refuses instead, silently.

There's no client-side fix for this — a <script> tag's request can't carry a custom header. The actual fix is infrastructural: serve static assets from a second backend service that isn't behind IAP at all, the same pattern most production setups already use for CDN-hosted, content-hashed bundles. Since IAP toggles per backend service and not per URL path, this means standing up a second backend and a URL-map split, not flipping a flag.

sequenceDiagram
    participant Client
    participant IAP as IAP-gated LB
    participant Bypass as Non-IAP backend
    Client->>IAP: GET /page  (Authorization: Bearer <token, aud=IAP client>)
    IAP-->>Client: 200 HTML shell
    Client->>Bypass: GET /dist/app.bundle.js  (no header)
    Bypass-->>Client: 200 application/javascript
    Note over Client: page hydrates normally

The client fan-out cost. Every environment an OAuth client needs to reach IAP for is its own client registration — and on Android, its own SHA-1 fingerprint per signing key. Give every stage tier its own bundle ID and you're registering a client (and, per developer, a debug-keystore fingerprint) for every environment × every machine. That stops scaling past a handful of people fast. Teams that hit this usually collapse it the same way: one non-prod build with a runtime environment picker instead of a build-time bundle ID per tier, so the OAuth client count stays fixed regardless of how many stage environments exist.

flowchart LR
    A[EnvironmentPicker: pick backend] --> B[persist choice]
    B --> C[hydrateRuntimeConfig on boot]
    C --> D["config.* resolves to selected preset\n(webview URL, IAP client, feature flags)"]
    D --> E[AuthGate: does this preset need IAP?]
    E -->|yes| F[Google sign-in -> IAP token]
    E -->|no| G[skip straight through]

Two smaller gotchas round this out, both specific to embedding a web page inside a native WebView rather than to IAP itself: cookies leaking in from the native app's own API calls can corrupt the page's hydration state (fixed by running the WebView incognito, with sharedCookiesEnabled={false}), and naively remounting the WebView to reapply the header on navigation cancels every in-flight tap, not just the one redirect it meant to catch — the fix is swapping source in place instead of remounting.

<GatedWebView
  incognito
  sharedCookiesEnabled={false}
  source={source}
  onNavigationStateChange={handleNavigationStateChange}
/>
Enter fullscreen mode Exit fullscreen mode

Put together, that's a real amount of work to make a non-browser client behave like a browser IAP was designed for. It's worth asking, before signing up for it, whether the non-browser client needs to talk to the IAP-gated backend at all. If most of that access is internal — a CI job hitting staging, a build script, a developer's local tooling — a short-lived service-account token issued at build time, or just a VPN, is often simpler than teaching every client to carry a bearer token correctly. IAP earns its keep when the end user needs individually attributable, revocable access. It's a heavier tool than necessary for access that's really "this machine is allowed to talk to that machine."

Alternatives to IAP

IAP isn't the only way to gate a backend behind identity — worth knowing what else is on the table before committing to it:

  • Cloudflare Access — the same shape as IAP (edge proxy, OAuth redirect, per-request policy), but at Cloudflare's edge instead of GCLB, and not tied to GCP.
  • Tailscale / Tailscale Funnel — network-level identity over WireGuard; a client joins the tailnet once instead of carrying a token per request, which removes the header/audience problems entirely, at the cost of every client needing the Tailscale client installed.
  • AWS ALB + Cognito — the AWS-native equivalent of the same redirect-and-cookie pattern, for teams already on that load balancer.
  • A plain VPN — gates at the network layer, completely invisible to the application; simple for internal-only, non-browser access, but the VPN client itself becomes the thing every user and machine has to run.
  • mTLS — certificate-based, and uniform across browser and non-browser clients since there's no OAuth redirect involved at all; the cost moves to certificate distribution and rotation instead.
  • A custom JWT-checking middleware — full control over the token shape and validation logic, and full ownership of maintaining it, which is exactly the auth layer IAP exists to let you skip.

The deciding question is usually whether your non-browser clients can tolerate a redirect-based flow at all. If they can, IAP or Cloudflare Access are the least amount of new infrastructure. If they can't — or if the access is really machine-to-machine — a VPN, Tailscale, or mTLS avoids the token dance altogether.

Closing

Securing a staging environment usually just means keeping it off the open internet while letting the right people straight through, and for a browser-facing surface, IAP does that with a toggle and an IAM entry instead of a service you have to run. It stops being free the moment a non-browser client needs through — budget for that as a real cost, not a footnote, and check first whether that client actually needs individually-attributable access at all, or just network-level access a simpler tool already provides.

Top comments (0)