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 the starting of this writing on August 21st, and after a brief deadline extension, 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() will only request the unapproved scopes from the list you pass in oAuthScopes. In fact, requireAllScopes() opens a dialog identical to the one you'd get from 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 nicely (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 this call 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.
And a moment later, 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 for both scopes will be displayed. 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've spotted authorization URLs in a few places:
- Returned by getAuthorizationUrl().
- In the execution log when an authorization error isn't caught or a require* method is called (e.g., the visible "click here to provide permissions" link).
It turns out that these URLs are not set in stone. We can customize the consent screen's behavior by manipulating three key query parameters (love query parameters, even mantain a site full of them).
I've summarized my research in this Google Doc, but the key takeaways can be found below:
Base URL: https://script.google.com/macros/d/SCRIPT_ID/authorize?
Query parameter | Customization |
enable_granular_consent=true | This is the default. It shows checkboxes if more than one scope is requested. If you omit it, checkboxes are disabled, forcing an all-or-nothing choice for the requested scopes. |
scopes=scope+scope+... | Use this to specify exactly which scopes you want to ask for, separated by a [+ ] sign. If you omit it, the dialog defaults to requesting all unauthorized scopes for the project. |
addrequired=false | This is the secret sauce. When used with the scopes parameter, it restricts the consent screen to only the scopes you've specified. Without it, Google will add all other unathorized scopes if granular consent is enabled, or all scopes otherwise, to the dialog anyway. |
Let's say a script has the following authorization status:
❌ https://mail.google.com/
❌ https://www.googleapis.com/auth/calendar
✅ https://www.googleapis.com/auth/drive.readonly
❌ https://www.googleapis.com/auth/spreadsheets
If a user action only needs Gmail and Calendar, we can build a custom authorization URL that asks for just those two scopes, with no checkboxes:
https://script.google.com/macros/u/0/d/SCRIPT_ID/authorize
?scopes=https://mail.google.com/+https://www.googleapis.com/auth/calendar
&addrequired=false
This is super 🎉 because it finally gives us fine-grained control over the consent screen's look and feel, allowing us to create a truly context-aware and user-friendly authorization experience.
⚠️ A word of warning: This is an unofficial, undocumented method. We are relying on URL structures and parameters that Google could change or remove at any time without notice. While powerful, using this in a production add-on carries a risk that you must be comfortable with.
Ideally, we wouldn't need to do this. A beefed-up getAuthorizationUrl() method that allows us to pass in the desired scopes and checkbox preference would be a fantastic and much-needed addition for all Apps Script developers.
Final musings & what's next in part 2
So, after all this digging, what's my take?
In my opinion, granular consent is a powerful and necessary feature for complex Workspace add-ons that might target different Google services. However, for many simpler editor add-ons, it can feel like overkill.
This brings up a bigger question about the balance between user freedom and developer freedom. As developers, we've been encouraged for years to request the narrowest possible set of scopes. If we've already done our due diligence, shouldn't we be able to designate some as truly mandatory?
For an add-on that only interacts with the current spreadsheet and custom dialogs, scopes like spreadsheets.currentonly
and script.container.ui
could well be non-negotiable for it to function at all. A technical option to enforce this, with common sense, would be a welcome addition.
This added complexity also speaks to a broader trend I see: the increasing "de-amateurization" of the Google Workspace Marketplace. It reminds me of the expensive, third-party security assessments required a few years back, which created a significant barrier for citizen developers and open-source folks like me who share their work for free (as in beer 🍻). Adding development hurdles, however well-intentioned, can make it harder for the community to thrive.
But let's not end on a sour note. Ultimately, the goal is to see granular consent not as another hoop to jump through, but as an opportunity to build more flexible and trustworthy tools. How this plays out will, of course, depend on how strictly policies are enforced in the Workspace Marketplace.
Talk is cheap, so in the next post, I'll show you the detailed, hands-on process of adapting two of my own add-ons to this new reality:
1️⃣ HdC+: A simple case with just two, very limited, scopes.
2️⃣ Form Response Control: A slightly more complex beast with multiple scopes and an installable trigger.
We'll turn all this theory into practice.
And just as you get this sorted out, don't forget what's next on the horizon... the discontinuation of the Rhino runtime is fast approaching its January 31st, 2026 deadline. The life of a Workspace dev is never dull, is it? 😉
Stay tuned for Part 2, coming very soon!