appscript.dev
Automation Advanced

Handle OAuth 2.0 in Apps Script

Connect Northwind to APIs that require user authorisation — using the OAuth2 library.

Published Jun 29, 2025

Plenty of the APIs Northwind wants to reach — accounting tools, project trackers, CRMs — do not accept a static API key. They require OAuth 2.0: the user is sent to the provider’s site, approves access, and the provider hands back a token your script then uses on their behalf. Doing that by hand means juggling redirect URLs, authorisation codes, token exchanges and refreshes, and it is easy to get subtly wrong.

This article wires up the community OAuth2 library, which handles the whole dance. You define the service once, the user clicks an authorisation link the first time, and from then on every call uses a token the library refreshes automatically. The example connects to a generic “my-api” provider; swap the endpoints for the real ones and it works the same.

What you’ll need

  • The OAuth2 library installed in your project (steps below).
  • A registered application with the provider, giving you a client ID and a client secret. Store both in Script Properties rather than in source — see Store API keys and secrets securely.
  • The provider’s authorisation URL, token URL and the scopes your script needs.
  • Your script’s redirect URI added to the provider’s allowed list — it must be exactly https://script.google.com/macros/d/SCRIPT_ID/usercallback.

Install the library

  1. In the Apps Script editor, click Libraries (the + next to Libraries in the left sidebar).
  2. Paste the library script ID: 1B7FSrk5Zi6L1rSxxTDgDEUsPzlukDsi4KGuTMorsTQHhGBzBkMun4iDF.
  3. Click Look up, pick the latest version, and add it with the identifier OAuth2.

The script

// Provider endpoints — replace with the real values from the API's docs.
const AUTH_BASE_URL = 'https://provider.example.com/oauth/authorize';
const TOKEN_URL = 'https://provider.example.com/oauth/token';

// Space-separated scopes the script needs.
const OAUTH_SCOPE = 'read write';

// A short name for this service. The OAuth2 library uses it to namespace
// the stored token, so keep it stable across the project.
const SERVICE_NAME = 'my-api';

/**
 * Builds the OAuth2 service. Every function that touches the provider
 * calls this — defining it in one place keeps the config consistent.
 *
 * @returns {OAuth2.Service} The configured, reusable service object.
 */
function getMyService() {
  // Client ID and secret live in Script Properties, never in source.
  const props = PropertiesService.getScriptProperties();
  const clientId = props.getProperty('OAUTH_CLIENT_ID');
  const clientSecret = props.getProperty('OAUTH_CLIENT_SECRET');

  return OAuth2.createService(SERVICE_NAME)
    .setAuthorizationBaseUrl(AUTH_BASE_URL)
    .setTokenUrl(TOKEN_URL)
    .setClientId(clientId)
    .setClientSecret(clientSecret)
    // The library calls this function when the provider redirects back.
    .setCallbackFunction('authCallback')
    // Tokens are kept per user, so each Northwind user authorises once.
    .setPropertyStore(PropertiesService.getUserProperties())
    .setScope(OAUTH_SCOPE);
}

/**
 * Logs the authorisation URL the user must visit to grant access.
 * Run this once to start the connection.
 */
function showAuthorizationUrl() {
  const service = getMyService();

  if (service.hasAccess()) {
    Logger.log('Already connected — no action needed.');
    return;
  }
  Logger.log('Open this URL to authorise:\n' + service.getAuthorizationUrl());
}

/**
 * The OAuth2 library redirects the user here after they approve (or deny)
 * access. It completes the token exchange and shows a result page.
 *
 * @param {Object} request  The callback request from the provider.
 * @returns {HtmlOutput}    A page confirming success or failure.
 */
function authCallback(request) {
  const isAuthorised = getMyService().handleCallback(request);
  return HtmlService.createHtmlOutput(
    isAuthorised
      ? 'Connected. You can close this tab.'
      : 'Authorisation failed. Please try again.'
  );
}

/**
 * Makes an authenticated request to the provider. The OAuth2 library
 * supplies a valid access token and refreshes it transparently.
 *
 * @param {string} path  The API path to call, e.g. '/v1/projects'.
 * @returns {Object}     The parsed JSON response.
 */
function callApi(path) {
  const service = getMyService();

  // No token yet — point the caller at the authorisation step.
  if (!service.hasAccess()) {
    throw new Error('Not connected. Run showAuthorizationUrl first.');
  }

  const response = UrlFetchApp.fetch('https://provider.example.com' + path, {
    headers: { Authorization: 'Bearer ' + service.getAccessToken() },
    muteHttpExceptions: true,
  });
  return JSON.parse(response.getContentText());
}

/**
 * Disconnects the current user, clearing their stored token. Useful for
 * testing, or when a user wants to revoke access.
 */
function resetAuth() {
  getMyService().reset();
  Logger.log('Authorisation cleared for the current user.');
}

How it works

  1. getMyService is the single place the OAuth2 service is defined. It pulls the client ID and secret from Script Properties and wires in the provider’s authorisation and token URLs, the scopes, and the callback function name.
  2. The property store is set to getUserProperties, so each Northwind user’s token is stored against their own account. One user authorising does not connect everyone else.
  3. showAuthorizationUrl checks whether the current user already has access. If not, it logs the URL they must visit to grant it.
  4. When the user approves at the provider, the provider redirects to the script’s usercallback URL. The library routes that to authCallback, which calls handleCallback to exchange the authorisation code for an access token and stores it.
  5. callApi makes authenticated requests. It calls getAccessToken, which returns a valid token — refreshing it behind the scenes if it has expired — and attaches it as a Bearer header.
  6. resetAuth clears the stored token for the current user, which is handy for testing the flow end to end or letting a user disconnect.

Example run

First-time setup, run once per user:

  1. Run showAuthorizationUrl. It logs an authorisation URL.
  2. Open the URL, sign in at the provider and approve the requested scopes.
  3. The provider redirects back; authCallback shows “Connected.”

After that, calls just work:

const projects = callApi('/v1/projects');
Logger.log('Fetched ' + projects.length + ' projects.');
StageWhat happens
Before authorisingcallApi throws “Not connected.”
After authorisingcallApi returns data; token refreshed silently as needed
After resetAuthBack to “Not connected.” until the user re-authorises

Run it

OAuth flows are interactive, so they run on demand, not on a trigger:

  1. Save the client ID and secret in Project Settings → Script Properties as OAUTH_CLIENT_ID and OAUTH_CLIENT_SECRET.
  2. Run showAuthorizationUrl, open the logged URL, and approve access.
  3. Run callApi (or any function that calls it) to use the connection.

To make this usable by non-editors, expose showAuthorizationUrl through a custom menu or a web app so each user can authorise themselves without opening the script editor.

Watch out for

  • The redirect URI must be registered with the provider exactly as https://script.google.com/macros/d/SCRIPT_ID/usercallback, using the script’s own ID. A mismatched URI is the most common cause of a failed callback.
  • Tokens are stored per user via getUserProperties. A token from one user is never available to another — by design — so every user authorises once.
  • Refresh handling is automatic, but only if the provider issued a refresh token. Some providers only return one when you request offline access or a specific scope — check the provider’s docs if connections silently expire.
  • If you change the client ID, secret, scopes or SERVICE_NAME, existing stored tokens become invalid. Run resetAuth and have users re-authorise.
  • Never log or expose getAccessToken’s return value. A leaked token grants the same access the user approved.
  • The OAuth2 library is community-maintained, not part of Apps Script. Pin a specific version rather than tracking the head, so an upstream change cannot break the connection without warning.

Related