●MODEL — Gemini 3.5 Flash is generally available as Google's top pick for agentic and coding tasks●AGENT — Managed Agents enter public preview in the Gemini API, running in isolated Linux sandboxes●WEBHOOK — Event-driven webhooks now cover the Batch API and long-running ops, removing polling●SECURITY — From June 19, requests from unrestricted API keys are blocked — review your key limits●DEPRECATED — Two image-preview models shut down June 25 — migrate any preview-dependent flows●CODEASSIST — Since June 18, individual Code Assist extensions and CLI stopped serving Pro/Ultra tiers●MODEL — Gemini 3.5 Flash is generally available as Google's top pick for agentic and coding tasks●AGENT — Managed Agents enter public preview in the Gemini API, running in isolated Linux sandboxes●WEBHOOK — Event-driven webhooks now cover the Batch API and long-running ops, removing polling●SECURITY — From June 19, requests from unrestricted API keys are blocked — review your key limits●DEPRECATED — Two image-preview models shut down June 25 — migrate any preview-dependent flows●CODEASSIST — Since June 18, individual Code Assist extensions and CLI stopped serving Pro/Ultra tiers
Keeping Apps Script + Gemini Automations on Least Privilege: Explicit Scopes and Catching Scope Creep
Apps Script automations that call Gemini quietly accumulate OAuth scopes. Here is how to declare explicit scopes in appsscript.json, catch scope creep in CI, and avoid forcing every user to re-consent.
One morning I made a tiny edit to a Sheets automation that had been running quietly for about six months, redeployed it, and was met with a re-authorization screen. The line item read: "Read, compose, send, and permanently delete all your email from Gmail." All I had changed was appending one row to a sheet. I had not touched Gmail at all.
It asked for that permission anyway.
The reason was mundane. Months earlier I had called GmailApp once during a test, commented the line out, and forgotten to delete it. Apps Script statically scans your code and infers scopes from APIs that look used. A single line inside a comment was enough for it to request one of the broadest scopes available.
When you run several automations across Workspace as an indie developer, these auto-inferred scopes quietly swell over time. A script in production ends up holding read and write permissions it never actually needs. It is a dull but heavy liability: it widens the blast radius of any incident without you ever deciding to.
This article is about cutting that liability. Using a typical automation that spans Gmail, Sheets, and the Gemini API, we will declare the minimum scopes in appsscript.json, catch creep in CI, and avoid the re-consent accidents that scope changes cause.
Why auto-inferred scopes are dangerous
Apps Script has two ways to decide scopes. If you declare nothing, it infers them from your code. If you list them under oauthScopes in appsscript.json, inference stops and only the scopes you declared are requested.
Auto-inference is dangerous because three problems stack on top of each other.
Problem
What actually happens
It grabs oversized scopes
A single GmailApp.search() pulls in "full read/write/delete of mail." You wanted read-only, but you now hold delete.
Dead code grants power
Calls left in comments or unreachable branches still feed inference. You request permissions you never exercise.
Change is invisible
Nothing records who widened a permission or when. It never enters review, so creep goes unnoticed.
The principle of least privilege is that code holds only the permissions it needs right now. Auto-inference is fundamentally at odds with that.
The automation we will use
Let's work from a concrete setup, close to one I actually run:
Read unread mail under a specific Gmail label (never send, never delete)
Pass the body to the Gemini API to summarize and classify
Append the result to a single spreadsheet
The permissions this automation truly needs come down to three:
Read Gmail, and nothing more (gmail.readonly)
Read/write the one spreadsheet it is bound to (spreadsheets.currentonly)
Outbound HTTP requests, to call the Gemini API (script.external_request)
No send permission. No Drive-wide permission. Left to inference, send and delete rights creep right in.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Step-by-step way to declare oauthScopes in appsscript.json and shut off the broad scopes Apps Script auto-assigns
✦A complete, copy-ready CI script that diffs declared scopes against an allowlist and fails on creep
✦How to roll out scope changes in stages so you never force every user into a surprise re-consent
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
In the Apps Script editor, under Project Settings, enable "Show appsscript.json manifest file in editor" so you can edit the manifest directly. Write oauthScopes like this:
Choosing gmail.readonly locks you to read-only — no delete, no send. If you call a write method on GmailApp under this scope, it fails at runtime, which forces "read only." That is not a constraint; it is a safety net.
spreadsheets.currentonly limits access to the single spreadsheet the script is bound to, rather than spreadsheets (every spreadsheet). Whenever currentonly is available, take it. It only works for container-bound scripts.
script.external_request is required to use UrlFetchApp. Since Apps Script has no dedicated Gemini client, we call the REST endpoint via UrlFetchApp.
Calling Gemini with the minimum scope
Here is the Gemini call that needs only the external-request scope. The API key lives in Script Properties, never hardcoded.
function summarizeWithGemini(text) { const apiKey = PropertiesService .getScriptProperties() .getProperty('GEMINI_API_KEY'); if (!apiKey) throw new Error('GEMINI_API_KEY is not set'); const model = 'gemini-2.5-flash'; const url = 'https://generativelanguage.googleapis.com/v1beta/models/' + model + ':generateContent'; const payload = { contents: [{ role: 'user', parts: [{ text: 'Summarize this email in two sentences:\n\n' + text }] }], generationConfig: { temperature: 0.2, maxOutputTokens: 256 } }; const res = UrlFetchApp.fetch(url, { method: 'post', contentType: 'application/json', headers: { 'x-goog-api-key': apiKey }, payload: JSON.stringify(payload), muteHttpExceptions: true }); const code = res.getResponseCode(); if (code !== 200) { throw new Error('Gemini API error ' + code + ': ' + res.getContentText()); } const data = JSON.parse(res.getContentText()); return data.candidates?.[0]?.content?.parts?.[0]?.text ?? '';}
The key is passed via the x-goog-api-key header rather than Authorization, because that is what the Generative Language API expects. Keeping it in Script Properties means the key never leaks even if the code is shared or copied.
The mail-reading side is written to never call a write method, either.
function processUnread() { const threads = GmailApp.search('label:to-summarize is:unread', 0, 20); const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheets()[0]; threads.forEach(function (thread) { const msg = thread.getMessages()[0]; const summary = summarizeWithGemini(msg.getPlainBody()); sheet.appendRow([new Date(), msg.getSubject(), summary]); // No markRead(): it would require gmail.modify and break least privilege. // We track "processed" on the Sheet side instead. });}
I deliberately avoid thread.markRead() here. markRead pulls in gmail.modify, which erases the point of narrowing to gmail.readonly. Rather than reach for a Gmail write, I keep the "already processed" record on the Sheet and let Gmail stay read-only. Least privilege often means shifting how a feature is implemented by one notch. That judgment is, to me, the real substance of the design.
Catching scope creep in CI
Declaring explicit scopes does not help if someone later — including future you — adds a broad one. So we diff the declared scopes against an allowlist and fail when anything unexpected appears.
Pull appsscript.json locally with clasp, then check it with this Node script. One scope outside the allowlist and it exits 1.
// scope-audit.mjs — diff appsscript.json scopes against an allowlistimport { readFileSync } from 'node:fs';const ALLOWED = new Set([ 'https://www.googleapis.com/auth/gmail.readonly', 'https://www.googleapis.com/auth/spreadsheets.currentonly', 'https://www.googleapis.com/auth/script.external_request',]);// Especially dangerous scopes — fail immediately if they appear.const FORBIDDEN = new Set([ 'https://mail.google.com/', // full Gmail 'https://www.googleapis.com/auth/gmail.modify', 'https://www.googleapis.com/auth/drive', // full Drive 'https://www.googleapis.com/auth/spreadsheets', // all spreadsheets]);const manifest = JSON.parse(readFileSync('./appsscript.json', 'utf8'));const scopes = manifest.oauthScopes ?? [];if (scopes.length === 0) { console.error('FAIL: oauthScopes is undeclared (inference will bloat it)'); process.exit(1);}const forbidden = scopes.filter((s) => FORBIDDEN.has(s));const unexpected = scopes.filter((s) => !ALLOWED.has(s));if (forbidden.length) { console.error('FAIL: forbidden scope present:\n ' + forbidden.join('\n '));}if (unexpected.length) { console.error('FAIL: scope outside allowlist:\n ' + unexpected.join('\n '));}if (forbidden.length || unexpected.length) process.exit(1);console.log('OK: ' + scopes.length + ' scopes, all within allowlist');
Run this on push in GitHub Actions and you stop "it was supposed to read Gmail, but a send scope crept in" before merge. A scope is a security boundary, not a feature, so it deserves the same review weight as code.
When you do extend the allowlist, the diff always surfaces in the pull request. Requiring a one-line comment for why a scope became necessary will save you six months later.
The "everyone re-consents" trap
Chasing least privilege has an easy-to-miss operational trap: changing oauthScopes forces re-consent on users who already authorized the script.
For a script only you use, it is one re-approval. But if you distribute it across an organization, or ship it as an add-on used by many people, adding a single scope triggers the re-authorization flow for every user. Treat that lightly and one morning your whole user base hits a "permissions changed" screen and the support requests pour in.
The fix is to treat scope changes as releases:
First question whether the change is truly needed, or avoidable on the feature side (like the markRead example above).
Record the added scope and its reason in the changelog and release notes.
For distributed code, announce that re-consent will be required before you ship.
Where possible, batch multiple scope changes into one release to minimize re-consent rounds.
Treat scopes as a contract with users rather than a detail of the code, and this discipline becomes second nature.
Migrating an existing script to least privilege
When tightening a script that is already live and bloated, do not change production directly. This order worked safely for me.
First, inventory the current scopes. Check what the script actually holds via Apps Script Project Settings, or under "Third-party access" in your Google Account security settings.
Next, declare in appsscript.json only the ones from that inventory you genuinely use. At that moment, inference stops.
Then run the whole thing in a throwaway copy project. Any missing scope surfaces as a runtime error. Add only the scopes those errors demand, each with a reason. This "find the gap through errors" approach is more reliable than guessing broad up front, and it converges on the minimum.
Finally, wire scope-audit.mjs into CI so it never bloats again.
What running this surfaced
After running several automations this way for a while, a few things stood out that the official docs do not spell out.
The boundary between gmail.readonly and gmail.modify gets crossed more often than you would think. Seemingly trivial operations — marking unread as read, swapping a label — drag in modify. Leaning toward designs that finish with reads alone keeps the scope light, too.
spreadsheets.currentonly is powerful but only works for container-bound scripts. If you build a standalone script that touches multiple sheets, you slide back to full spreadsheets. If you are building a sheet integration, start it container-bound and least privilege is easier to hold.
Above all, there is an asymmetry: scopes are easy to widen and hard to shrink. Widening is a re-consent away, but shrinking can leave stale consent behind, so it does not always reflect cleanly for users. That is exactly why starting narrow is the shortcut.
Tightening permissions is unglamorous and adds no feature. Yet I have come to feel that this dull boundary is what holds up the assumption that an automation keeps running quietly. If you are growing your own automations on Workspace, I hope this gives you a reason to audit yours.
Gemini Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.