
Taming granular OAuth consent in Google Apps Script | Part 1: The basics
Heck! No doubt, the life of a (Workspace) developer is a long, winding road where each and every turn can hide unexpected surprises. This time, however, the change is one we've seen coming.
Back in January 2025, we got a taste of granular OAuth consent in the Apps Script IDE. Just a few months later, in May, Google Workspace add-ons received the same treatment.
Now, as of this writing on August 21st, and after a brief delay, it's rolling out for the good-old editor add-ons we all love—and it's happening faster than you can say "Gemini". Yep, August 26th is the day.
This impactful subject has already been covered by Martin Hawksey in his encyclopedic—to say the least—AppsScript Pulse, and some developers have already shared clever snippets to handle this important change. You might want to peek at those resources to get the full backstory before reading on, or even skip this post entirely and call it a day 😅.
However, after spending some time analyzing the new Apps Script classess and how they can be leveraged to handle the partial authorization of the scopes, I feel that there are some nuances that deserve a closer look.
What follows, in this two-part series, is a blend of technical details and my personal take on this new feature, with an emphasis on editor-bound scripts and add-ons (still not to keen on Workspace add-ons, you know 🙃). I promise I'll speak my mind, but also my heart, even if it means being a little politically incorrect.
Table of Contents
What's going on with scopes?
I assume you know one thing or two about authorization scopes in Apps Script, which by the way, aren't exclusive to Apps Script or Google, but are fundamental to OAuth. Still, let's test the waters and make sure we're all on the same page before diving in.
Long story short:
- You write fantastic Apps Script code to achieve wondrous things. More often than not, that involves interacting with a user's data stored in one or more Google Services: Sheets, Slides, Calendar, Classroom... you name it!
- The Apps Script IDE is smart enough to scan your code and pick up the permissions (scopes) your script needs to fulfill its glorious destiny. Sometimes it's over-cautious and includes commented-out code. Other times it's over-indulgent and chooses excessively permissive scopes. This can be a problem when publishing your work in the ever-vigilant Google Workspace Marketplace, forcing us devs to set the scopes explicitly in that little box of wonders: the project's manifest file.
- Whenever a user runs your script for the first time, Google halts the execution and presents an informative consent screen that clearly shows the permissions the script needs.
- Users who have already granted these permissions can continue using your script normally. They won't see the consent screen again unless they intentionally revoke the permissions from their Google Account's security dashboard, or the script is updated to require new scopes.
👈 Before the dawn of granular OAuth consent:
Users authorized a script's scopes on an all-or-nothing basis. They had to take it or leave it. This gave the script's author the reasonable certainty that their code would run without issues arising from insufficient permissions.

👉 After the dawn of granular OAuth consent (i.e., now!):
Users can freely pick and choose which scopes to authorize. This gives them the upper hand in deciding what data they are comfortable sharing and which actions they'll allow the script to perform.

Google summarizes the current behavior of granular OAuth permissions in their official documentation.
Choices are usually a good thing. In this case, they foster user trust and a sense of security. But they also place an additional burden on the already-overloaded backs of developers, who are now expected to design a custom experience based on their project's permission status at runtime.
Let's see what we can do about that.
New Apps Script methods and classes to the rescue
The powerful ScriptApp class has learned some fancy new tricks to handle this new situation.
1️⃣ The getAuthorizationInfo(authMode, [oAuthScopes]) method returns an AuthorizationInfo object, which will let us delve deeper into the current authorization status.
- The first parameter, authMode, should be
ScriptApp.AuthMode.FULL
, as we're interested in the user's permission grants - The second, optional parameter, oAuthScopes, is a string array used to restrict the analysis to a specific list of scopes.
2️⃣ The AuthorizationInfo object, in turn, provides three key methods:
- getAuthorizationStatus(): Returns an AuthorizationStatus value (
REQUIRED
,NOT_REQUIRED
). TheNOT_REQUIRED
status confirms that the user has already granted all the scopes the script needs. - getAuthorizationUrl(): This handy method returns an URL that you can use to open a consent screen. This screen will ask for consent only for the scopes that have not yet been authorized. Yes, all of them!
- getAuthorizedScopes(): This returns a string array of all scopes that have already been authorized. It honors the outhScopes parameter from the initial getAuthorizationInfo() call, meaning the list is restricted to scopes you originally asked about.
This set of methods does not attempt any proactive actions. Instead, they are intended for developers to gather the script's authorization status. This allows us to provide users with what the official docs insistently call a "custom experience based on their permission status". We'll tackle that elusive UX thingy in just a minute.
3️⃣ Besides polling for status, we can also wield two additional, certainly more straightforward, methods:
As you might have guessed (you're a clever dev, after all), these methods will immediately pop up a consent screen. The first one, requireAllScopes(), asks for all unapproved scopes, while requireScopes() asks only for the unapproved scopes from the list you pass in oAuthScopes. In fact, requireAllScopes() opens a dialog identical to the one you'd get from the getAuthorizationUrl().
Right, now you know the tools in your belt. But the devil is in the details...
Choices, choices... and details!
Based on the new Apps Script methods we have just unravelled, we have three possible paths in this brave new world of granular consent:
- Check the authorization status first. We can present a custom dialog explaining why the script needs additional scopes, and then direct the user to the authorization link from getAuthorizationUrl().
- Do nothing. Yep, this is an option, too 😅!
- Call the require* methods blindly. We can just use requireAllScopes() or requireScopes() and let the Apps Script backend do its OAuth magic to handle the consent screen hassle for us.
All three strategies have specific caveats and their own subtleties. Let me elaborate using this sample script as a testing ground:
☝️ Mind that granular consent is now the new normal for standalone and editor-bound scripts, as well as for Workspace and Editor add-ons. For simplicity, I will use a standalone script in this section.
function checkScopes() {
// Retrieves information about the authorized scopes
const auth = ScriptApp.getAuthorizationInfo(ScriptApp.AuthMode.FULL);
const authStatus = auth.getAuthorizationStatus();
console.info(authStatus == ScriptApp.AuthorizationStatus.NOT_REQUIRED ? 'NOT_REQUIRED' : 'REQUIRED');
console.info(auth.getAuthorizationUrl());
console.info(auth.getAuthorizedScopes());
}
function checkScopesBeforeAction() {
const scriptScopes = [
'https://mail.google.com/',
'https://www.googleapis.com/auth/calendar',
'https://www.googleapis.com/auth/drive.readonly'
];
const auth = ScriptApp.getAuthorizationInfo(ScriptApp.AuthMode.FULL);
if (auth.getAuthorizationStatus() == ScriptApp.AuthorizationStatus.REQUIRED) {
const authorizationUrl = auth.getAuthorizationUrl();
const authorizedScopes = auth.getAuthorizedScopes();
const unauthorizedScopes = scriptScopes.filter(scope => !authorizedScopes.includes(scope));
console.info('⚠️ Authorization Alert!',
`This script requires additional scopes:\n\n` +
`${unauthorizedScopes.join('\n')}\n\n` +
`❌ The current action is cancelled. Please copy & paste this URL into a new tab, reauthorize, and try again:\n\n` +
`${authorizationUrl}`);
} else {
// Action's code here!
}
}
function doSomething() {
try {
console.info("Let's go...");
// ScriptApp.requireAllScopes(ScriptApp.AuthMode.FULL);
console.info('Testing Calendar...');
// ScriptApp.requireScopes(ScriptApp.AuthMode.FULL, ['https://www.googleapis.com/auth/calendar']);
const c = CalendarApp.getAllCalendars();
console.info('Calendar OK');
console.info(c.length);
console.info('Testing Gmail...');
// ScriptApp.requireScopes(ScriptApp.AuthMode.FULL, ['https://mail.google.com/']);
const g = GmailApp.getDraftMessages();
console.info('Gmail OK');
console.info(g.length);
console.info('Testing Drive...');
// ScriptApp.requireScopes(ScriptApp.AuthMode.FULL, ['https://www.googleapis.com/auth/drive.readonly']);
const d = DriveApp.getFiles();
console.info('Drive OK');
console.info(d.hasNext);
// Exceptions caused by lack of authorization can be captured,
// but the error object does not include the authorization URL
} catch (e) {
console.error('## Runtime error ##\n' + e.message);
}
}
You can easily check in the ℹ️ Overview section of the Apps Script IDE that our little snippet of code needs, for no particularly useful purpose 😜, these three scopes:
https://mail.google.com/
https://www.googleapis.com/auth/calendar
https://www.googleapis.com/auth/drive.readonly
Check the authorization status first
Let's walk through this strategy step-by-step.
First, make a copy of the script and run the checkScopes() function from the IDE.
function checkScopes() {
// Retrieves information about the authorized scopes
const auth = ScriptApp.getAuthorizationInfo(ScriptApp.AuthMode.FULL);
const authStatus = auth.getAuthorizationStatus();
console.info(authStatus == ScriptApp.AuthorizationStatus.NOT_REQUIRED ? 'NOT_REQUIRED' : 'REQUIRED');
console.info(auth.getAuthorizationUrl());
console.info(auth.getAuthorizedScopes());
}
When the new consent screen appears (nothing unsual as this is your first run of the script), check only the Drive scope, leave the other two unchecked, and then click Continue.

The consent screen will close and the function will run, populating the execution log with valuable information gathered from the AuthorizationInfo object.

From the log, we can see:
- That further authorization is indeed needed (
REQUIRED
). - The authorization URL for the remaining scopes. This would be
null
if all scopes were granted. - The only scope that has been authorized so far:
drive.readonly
.
Now, copy the authorization URL from your execution log and open it in a new browser tab.
https://script.google.com/macros/d/SCRIPT_ID/authorize?enable_granular_consent=true
You'll be presented with a new consent screen asking for the two remaining scopes we previously skipped. Notice that both are again unchecked by default.

For now, just hit the Cancel button, as we don't want to grant more permissions just yet.
Right, everything is working exactly as documented in the previous section, isn't it? But I mentioned some caveats...
One thing I am not totally sold on with this getAuthorizationInfo → getAuthorizationUrl route is that the URL provided by the latter method presents all unauthorized scopes at once. If our goal is to respect user choice, pushing a URL that insists on displaying scopes not required for the specific action the user just attempted seems intrusive. It feels less like offering a granular choice and more like stubbornly persuading users into a full-consent scenario.
Imagine a user clicks a menu item that only needs the Gmail scope. The consent dialog that eventually appears also shows an unrelated Calendar scope. In true European GDPR-compliant style 🫡, both scopes are unchecked by default. This makes sense for the unneeded Calendar scope but is confusing for the Gmail one, which is mandatory for the process to continue.
Of course, the intended workflow possibly includes showing a dialog to explain that some scopes are missing (and maybe why). But this leads to its own problems:
- The trusty alert dialogs are rather crude and can't show a clickable link. Fortunately, they don't require authorization to use.
- The much nicer custom HTML dialogs and sidebars require the
script.container.ui
scope. This could be unauthorized right when we need it most—a classic chicken-and-egg problem.
To my eyes, this flow is fundamentally broken. Users are expected to manually copy and paste an URL, grant permissions (while navigating potential over-consent), and then try the original action all over again.

Heads up: there’s no getUnauthorizedScopes() method. To create a helpful alert like the one above, you have to find the missing scopes yourself by checking what's in your script's full scope list versus what getAuthorizedScopes() tells you.
Something like this should do the trick (lines 12-35):
function checkScopesBeforeAction() {
const scriptScopes = [
'https://mail.google.com/',
'https://www.googleapis.com/auth/calendar',
'https://www.googleapis.com/auth/drive.readonly'
];
const ui = SpreadsheetApp.getUi();
const auth = ScriptApp.getAuthorizationInfo(ScriptApp.AuthMode.FULL);
if (auth.getAuthorizationStatus() == ScriptApp.AuthorizationStatus.REQUIRED) {
const authorizationUrl = auth.getAuthorizationUrl();
const authorizedScopes = auth.getAuthorizedScopes();
const unauthorizedScopes = scriptScopes.filter(scope => !authorizedScopes.includes(scope));
ui.alert('⚠️ Authorization Alert!',
`This script requires additional scopes:\n\n` +
`${unauthorizedScopes.join('\n')}\n\n` +
`❌ The current action is cancelled. Please copy & paste this URL into a new tab, reauthorize, and try again:\n\n` +
`${authorizationUrl}`,
ui.ButtonSet.OK);
} else {
// Action's code here!
}
}
You can try an analogous function in our test script (lines 12-35) that outpus information to the execution log instead.

Call me finicky, but this all feels rather unsatisfactory and awkward 🤷.
☝️ As I see it, this strategy should probably be reserved for when an action can run with degraded functionality if some scopes are missing, as this little snippet in the official documentation demonstrates. Hello, "custom user experience." But please, don't forget to provide a way for users to grant full permissions and upgrade to the magnificent full experience your script is capable of.
Hint: A menu item for that would do the job quite nice (keep reading).
What's next?
Do nothing
You might be thinking: "Hey, what if I choose to do nothing at all? Will my script still run?"
The short answer is a resounding yes!
Remember our little test script? We left it a few minutes ago with this authorization status:
❌ https://mail.google.com/
❌ https://www.googleapis.com/auth/calendar
✅ https://www.googleapis.com/auth/drive.readonly
Now, run the doSomething() function. For the moment, keep the ScriptApp.require... lines commented out.
function doSomething() {
try {
console.info("Let's go...");
// ScriptApp.requireAllScopes(ScriptApp.AuthMode.FULL);
console.info('Testing Calendar...');
// ScriptApp.requireScopes(ScriptApp.AuthMode.FULL, ['https://www.googleapis.com/auth/calendar']);
const c = CalendarApp.getAllCalendars();
console.info('Calendar OK');
console.info(c.length);
console.info('Testing Gmail...');
// ScriptApp.requireScopes(ScriptApp.AuthMode.FULL, ['https://mail.google.com/']);
const g = GmailApp.getDraftMessages();
console.info('Gmail OK');
console.info(g.length);
console.info('Testing Drive...');
// ScriptApp.requireScopes(ScriptApp.AuthMode.FULL, ['https://www.googleapis.com/auth/drive.readonly']);
const d = DriveApp.getFiles();
console.info('Drive OK');
console.info(d.hasNext);
// Exceptions caused by lack of authorization can be captured,
// but the error object does not include the authorization URL
} catch (e) {
console.error('## Runtime error ##\n' + e.message);
}
}
The try...catch
block will kick in as soon as the script attempts to retrieve the list of calendars in line 47, logging an informative message.
console.info('Testing Calendar...');
ScriptApp.requireScopes(ScriptApp.AuthMode.FULL, ['https://www.googleapis.com/auth/calendar']);
const c = CalendarApp.getAllCalendars();
console.info('Calendar OK');
console.info(c.length);

The execution then stops because the catch block doesn't take any further action. Of course, we could have taken the getAuthorizationInfo path to handle the exception, but we are exploring now the easy-going route, so not really interested in doing anything but letting it go.
Okay, now remove the try...catch
block and run the doSomething() function again. You'll get a similar error message in the execution log.

But this time, the standard authorization flow will trigger smoothly.

Of course, If this were an editor-bound script or add-on, this modal would pop up in the UI.

If you hit Cancel, the execution terminates. But if you decide to proceed to review permissions, you'll be greeted by a familiar consent screen asking for access to the Calendar scope.

But look closer. This screen has two key differences from the other consent dialogs we've seen so far:
- It only requests the single scope needed by the specific call that failed (getAllCalendars()). No other unauthorized scopes that might confuse users are in sight.
- There are no checkboxes. You just hit Continue to grant the permission or Cancel to deny it.
This seems like a frictionless, no-frills way to handle partial authorization, doesn't it?
Go ahead and click Continue ASAP to grant the Calendar scope. See what happens next.

The execution proceeded past the Calendar call, but now the Gmail-scoped getDraftMessages() call triggers an identical authorization request.
No matter how appealing this single-scope consent screen is, it's an unacceptable user experience to have multiple pop-ups appear, one by one, during a single script execution. That's a bit too granular, me thinks, even for granular-lovers!
Besides that, there is an additional, critical issue that plagues this "do-nothing" approach.
☝️ The easy-going "do-nothing" strategy won't work at all for installable triggers (doGet / doPost entry points would also fall in this category), as there is no interactive user to authorize the script at runtime.
We really need to look further!
Call the require* methods blindly
Okay, we have just one path left to explore. What can the requireAllScopes() and requireScopes() methods do for our granular OAuth chores? It turns out that wonderful things, actually.
First, let's uncomment all the require* methods in our doSomething() function (lines 43, 46, 52, and 58).
function doSomething() {
// try {
console.info("Let's go...");
ScriptApp.requireAllScopes(ScriptApp.AuthMode.FULL);
console.info('Testing Calendar...');
ScriptApp.requireScopes(ScriptApp.AuthMode.FULL, ['https://www.googleapis.com/auth/calendar']);
const c = CalendarApp.getAllCalendars();
console.info('Calendar OK');
console.info(c.length);
console.info('Testing Gmail...');
ScriptApp.requireScopes(ScriptApp.AuthMode.FULL, ['https://mail.google.com/']);
const g = GmailApp.getDraftMessages();
console.info('Gmail OK');
console.info(g.length);
console.info('Testing Drive...');
ScriptApp.requireScopes(ScriptApp.AuthMode.FULL, ['https://www.googleapis.com/auth/drive.readonly']);
const d = DriveApp.getFiles();
console.info('Drive OK');
console.info(d.hasNext);
// Exceptions caused by lack of authorization can be captured,
// but the error object does not include the authorization URL
/* } catch (e) {
console.error('## Runtime error ##\n' + e.message);
} */
}
Now, run the doSomething() function... and watch the magic unfold as the first line is executed.
ScriptApp.requireAllScopes(ScriptApp.AuthMode.FULL);


The requireAllScopes() method presents a consent screen for every unauthorized scope at once.

Just hit Cancel to abort the autorization flow.
In a nutshell, we get the exact same consent screen as the one from the getAuthorizationUrl() method (all missing scopes, unchecked by default), but without having to check the authorization status or build a custom alert first.
☝ The requireAllScopes() method doesn't scan the code to see what's coming next. It simply knows which of the project's scopes haven't been granted and swiftly displays a consent screen for all of them. Interesting, huh?
Right, let's move on. Comment out the requireAllScopes() line and run doSomething() again. This time, the execution proceeds until it hits the first requireScopes() call in line 46.
console.info("Let's go...");
// ScriptApp.requireAllScopes(ScriptApp.AuthMode.FULL);
console.info('Testing Calendar...');
ScriptApp.requireScopes(ScriptApp.AuthMode.FULL, ['https://www.googleapis.com/auth/calendar']);
const c = CalendarApp.getAllCalendars();
console.info('Calendar OK');
console.info(c.length);
Unsurprisingly, you'll see a new authorization flow trigger.



A truly granular consent screen, with no pesky checkboxes to fiddle with. Extremely convenient!
And what if we passed an array with two or more scopes? For example:
https://mail.google.com/
https://www.googleapis.com/auth/calendar

As expected, a consent screen appears for both scopes. However, this time —because more than one scope is requested— we can't get rid of those little, infuriating checkboxes, which seem to be back for vengeance 🥴. A bit inconsistent, I'd say.
After some pondering, my conviction is that the require* route is the one to follow in most cases (excluding installable triggers, of course). It's straightforward, proactive, and lets us authorize all the scopes needed in a script workflow at once while avoiding the awkward UX of the "check-first" method.
So, after strolling down three different paths towards the Valhalla of Granular Consent, we've found that none of them offers a completely satisfying journey.
Can we do better?
Sure we can. Welcome to the final piece of the puzzle.
Messing around with query parameters
So far in this post we have spotted authorization URLs at different points:
- Returned by getAuthorizationUrl().
- Shown in the execution log when an authorization exception is not catch'ed or a require* method is called (i.e., "click here to provide permissions" link).
These links lead to consent screens that behave in several ways, depending on the configuration of three query paramenters present in the distinc URLs. I've summarized it, together with some sample pics in this brief Google doc:
Base URL: https://script.google.com/macros/d/SCRIPT_ID/authorize?
Query parameter | Behaviour if used | Behaviour if not used |
enable_granular_consent=true | Displays checkboxes to choose scopes in the consent screen (if more than one). | Disables checkboxes. |
scopes=scope+scope+... | Displays for authorization the unauthorized scopes passed in. | Displays for authorization all unauthorized scopes. |
addrequired=false | Restricts the list of scopes displayed for authorization to the ones passed in the scopes query parameter. | Displays for authorization all unauthorized scopes (if granular consent is enabled), or all scopes (if it isn't). |
For instance, given this authorization status of a script:
❌ https://mail.google.com/
❌ https://www.googleapis.com/auth/calendar
✅ https://www.googleapis.com/auth/drive.readonly
❌ https://www.googleapis.com/auth/spreadsheets
The following URL would display a consent screen asking for the mail and calendar scopes only, with no trace of checkboxes.
https://script.google.com/macros/u/0/d/SCRIPT_ID/authorize
?scopes=https://www.googleapis.com/auth/calendar+https://mail.google.com/
&addrequired=false
And this is super 🎉 , because finally we do have a good enough control over the look & feel of the consent screen.
⚠️ There is a catch, though. We are relying on a non-officially endorsed way of pushing out consent screens. Query parameters or even the base authentication URL could change without notice, which is a risk you'd rather avoid at all costs.
Messing with the URL of the consent screen is far from ideal, it could break anytime, so I think that a beefed-up getAuthorizationUrl() method, which could be parameterized to restrict the specific scopes to present to the user and the choice of including checkboxes or not, could be very useful for us devs.
Final musings & what's next in part 2
Messing with the URL of the consent screen is far from ideal, it could break anytime, so I think that a beefed-up getAuthorizationUrl() method that could be parameterized to restrict the specific scopes to present to the user and the choice of including checkboxes or not could be very useful.
Screenshot of cancel error, also red alert of shame in editor-bound script or add-on???
Professional vs citizen: available time and effort
Freedom of users vs. Freedom of devs: I want to be able to require certain permissions, users are free no to install my app. All with measue and common sense.
Letting users restrict the usage scope to certain file types is totally valid IMHO. But some add-ons are targeting very specific use-cases, for which they need for sure all the required permissions thay are asking for (we have been encouraged for years now to use the smaller, more restrictive set of permissions in our published add-ons). For example, my HdC+ add-on only works on spreadsheets where it has been installed and just opens an occasional HTML dialog or sidebar. In very straightforward use cases, such as these, building the logic to handle partial authorizations seems rather overkill and totally unnecessary to me.
3️⃣ That blog reinforces my belief that granular consent is absolutely relevant for Workspace add-ons that can target different Google services, but not so much for editor add-ons, especially for the simpler ones, as you said, that usually need all the required scopes to fulfill their mission properly.
External audits and the de-amateurization of the Workspace Marketplate.
See this not as an obligation, but as a way to build more flexible add-ons.
Degree of enformcent of policies in the Workspace Maketplace (some "musts" in release notes are suspicious)
Next deadlines: Rhino (JDBC service) Jan 31st 2026
Raccord issues evident in the Execution log
Comentarios