DEV Community

Cover image for How I Built a Chrome Extension That Connects Securely to a Flask Backend (with Google OAuth + Cloud Run)
Saloni Kataria
Saloni Kataria

Posted on • Edited on

How I Built a Chrome Extension That Connects Securely to a Flask Backend (with Google OAuth + Cloud Run)

This is a developer tutorial. It focuses on copy‑pasteable steps, code, and gotchas for MV3 extensions talking to IAP/IAM‑protected Cloud Run. (I have a separate trade‑journal case study aimed at analytics leaders.)

What you’ll build

A Chrome Extension (MV3) that signs in with Google OAuth2 using chrome.identity.launchWebAuthFlow, obtains a Google ID token, and calls a Flask API on Cloud Run behind IAP/IAM. The backend verifies the token and (optionally) queries BigQuery.

High‑level flow

Extension (MV3)
  → launchWebAuthFlow → Google OAuth2 → ID token (JWT)
    → fetch with Authorization: Bearer <token>
      → Cloud Run (Flask; IAP/IAM)
        → verify token audience
          → BigQuery (optional)
Enter fullscreen mode Exit fullscreen mode

Prereqs

  • Chrome 121+ (MV3), a Google Cloud project
  • A Web application OAuth2 client (client_id)
  • A Cloud Run service (Flask) protected by IAP (preferred) or IAM
  • Python 3.10+, flask, google-cloud-bigquery, google-auth
  • Basic familiarity with JWTs and Cloud roles

Tip: If you use IAP, the token's audience must be the IAP OAuth Client ID for your Cloud Run service. If you use only IAM, the audience is often the service URL.


1) Minimal MV3 setup

manifest.json

{
  "manifest_version": 3,
  "name": "Secure Cloud Run Caller",
  "version": "1.0.0",
  "permissions": ["identity", "storage", "activeTab", "scripting"],
  "oauth2": {
    "client_id": "YOUR_OAUTH_CLIENT_ID.apps.googleusercontent.com",
    "scopes": ["openid", "email", "profile"]
  },
  "background": { "service_worker": "background.js" },
  "action": { "default_title": "Secure Cloud Run Caller", "default_popup": "popup.html" }
}
Enter fullscreen mode Exit fullscreen mode

popup.html (skeleton)

<button id="go">Call API</button>
<pre id="out"></pre>
<script src="popup.js"></script>
Enter fullscreen mode Exit fullscreen mode

2) Get an ID token with launchWebAuthFlow

background.js

async function getIdTokenInteractive() {
  const redirectUrl = chrome.identity.getRedirectURL();
  const u = new URL("https://accounts.google.com/o/oauth2/v2/auth");
  u.searchParams.set("client_id", "YOUR_OAUTH_CLIENT_ID.apps.googleusercontent.com");
  u.searchParams.set("response_type", "id_token");
  u.searchParams.set("scope", "openid email profile");
  u.searchParams.set("redirect_uri", redirectUrl);
  u.searchParams.set("nonce", String(Date.now()));
  u.searchParams.set("prompt", "select_account");

  const { url } = await chrome.identity.launchWebAuthFlow({ url: u.toString(), interactive: true });
  const hash = new URL(url).hash.substring(1); // "id_token=...&..."
  const params = new URLSearchParams(hash);
  const idToken = params.get("id_token");
  if (!idToken) throw new Error("No id_token returned");
  return idToken;
}
Enter fullscreen mode Exit fullscreen mode

Optional: cache tokens with an expiry buffer

background.js (continued)

function decodeJwtPayload(jwt) {
  const payload = jwt.split(".")[1];
  return JSON.parse(atob(payload.replace(/-/g, "+").replace(/_/g, "/")));
}

async function getCachedIdToken() {
  const now = Math.floor(Date.now() / 1000);
  const { cachedToken, cachedExp } = await chrome.storage.local.get(["cachedToken", "cachedExp"]);
  if (cachedToken && cachedExp && (cachedExp - 300) > now) { // 5‑min buffer
    return cachedToken;
  }
  const fresh = await getIdTokenInteractive();
  const { exp } = decodeJwtPayload(fresh);
  await chrome.storage.local.set({ cachedToken: fresh, cachedExp: exp });
  return fresh;
}
Enter fullscreen mode Exit fullscreen mode

3) Call the Cloud Run API

popup.js

document.getElementById("go").addEventListener("click", async () => {
  const out = document.getElementById("out");
  out.textContent = "Calling…";
  try {
    const token = await chrome.runtime.sendMessage({ type: "GET_TOKEN" });
    const res = await fetch("https://YOUR‑SERVICE‑HASH‑ue.a.run.app/run-secure-endpoint", {
      method: "POST",
      headers: { "Authorization": `Bearer ${token}`, "Content-Type": "application/json" },
      body: JSON.stringify({ url: (await chrome.tabs.query({ active: true, currentWindow: true }))[0].url })
    });
    out.textContent = res.ok ? JSON.stringify(await res.json(), null, 2) : `HTTP ${res.status}`;
  } catch (e) {
    out.textContent = e.message;
  }
});

// in background.js
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
  if (msg?.type === "GET_TOKEN") getCachedIdToken().then(t => sendResponse(t)).catch(e => sendResponse({ error: String(e) }));
  return true; // async
});
Enter fullscreen mode Exit fullscreen mode

4) Flask on Cloud Run (verify the token)

main.py

from flask import Flask, request, jsonify
from google.oauth2 import id_token
from google.auth.transport import requests as greq
import os

app = Flask(__name__)

# If using IAP, set to the IAP OAuth client ID. Otherwise, expected audience (e.g., service URL).
IAP_AUDIENCE = os.environ.get("IAP_OAUTH_CLIENT_ID", "")

def verify_google_id_token(token: str) -> dict:
  # Raises on failure; returns dict on success
  return id_token.verify_oauth2_token(token, greq.Request(), audience=IAP_AUDIENCE)

@app.after_request
def add_cors(resp):
  allow = os.environ.get("ALLOWED_ORIGIN", "*")
  resp.headers["Access-Control-Allow-Origin"] = allow
  resp.headers["Access-Control-Allow-Headers"] = "Authorization, Content-Type"
  resp.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS"
  return resp

@app.route("/run-secure-endpoint", methods=["POST", "OPTIONS"])
def run_secure():
  if request.method == "OPTIONS":
    return ("", 204)
  auth = request.headers.get("Authorization", "")
  if not auth.startswith("Bearer "):
    return jsonify({"error": "missing bearer token"}), 401
  try:
    payload = verify_google_id_token(auth.split()[1])
  except Exception as e:
    return jsonify({"error": "invalid token", "detail": str(e)}), 401

  # TODO: enforce domain, user allowlist, or role mapping here
  return jsonify({"ok": True, "email": payload.get("email")})
Enter fullscreen mode Exit fullscreen mode

Optional: BigQuery example

from google.cloud import bigquery

@app.get("/prices")
def prices():
  token = request.headers.get("Authorization", "")
  if not token.startswith("Bearer "):
    return jsonify({"error":"auth"}), 401
  verify_google_id_token(token.split()[1])  # raises if bad
  client = bigquery.Client()  # uses Cloud Run service account
  rows = client.query("SELECT 1 AS ok").result()
  return jsonify({"rows":[dict(r) for r in rows]})
Enter fullscreen mode Exit fullscreen mode

5) Troubleshooting 401s (quick fixes)

  • Wrong audience → For IAP, use the IAP OAuth Client ID as audience.
  • Redirect URI mismatch → Add the extension redirect pattern via chrome.identity.getRedirectURL() to your OAuth client.
  • Clock skew → Ensure system time is correct; add a 5‑minute buffer when caching tokens.
  • CORS → Return the Access-Control-* headers shown above (or stricter).
  • Roles → Caller must have roles/run.invoker; service account needs the minimal BigQuery roles.
  • Mixed IAP/IAM confusion → Pick one model and configure its audience/headers consistently.

6) Security checklist

  • [ ] No client secrets or long‑lived tokens in the extension
  • [ ] Verify JWT aud and exp on the server
  • [ ] Use allowlists (email/domain) before serving data
  • [ ] Keep scopes minimal (openid email profile)
  • [ ] Store tokens with an expiry buffer; refresh before use
  • [ ] Keep Cloud Run service account least‑privileged; pull secrets from Secret Manager if needed

Repo layout (suggested)

repo/
├── extension/
│   ├── manifest.json
│   ├── background.js
│   └── popup.{html,js}
└── backend/
    ├── main.py
    ├── requirements.txt
    └── Dockerfile
Enter fullscreen mode Exit fullscreen mode

Wrap‑up

You now have a working pattern for MV3 → OAuth2 ID token → IAP/IAM‑protected Cloud Run → Flask (→ BigQuery). From here you can add per‑route authorization, richer UI, and CI/CD. If you publish a case‑study version for non‑developers, link it as the canonical later.

Got questions or stuck on 401s? Comment here or ping me on GitHub @saloni28.

Top comments (0)