appscript.dev
Guide Advanced

Secure a web app: authentication and access

Control who can reach Northwind's web-app endpoints — three layers from URL to logic.

Published Sep 1, 2025

A web app turns an Apps Script project into a public URL that anyone can hit with a browser or an HTTP client. That is exactly what makes it useful — a form, a dashboard, a small API endpoint — and exactly what makes it risky. The moment you deploy, the question stops being “what does it do” and becomes “who is allowed to make it do that”.

Access control on a web app is not a single switch. It is a stack of independent checks, and a request only deserves a response if it passes all the checks that matter for that endpoint. This guide walks the three layers from the outside in, so you can pick the right combination for what you are building.

Three layers of defence

Think of access control as three concentric rings. Each one filters out a different category of unwanted caller, and they are strongest when combined.

  1. Deployment access setting. Set when you deploy: Anyone, Anyone with a Google account, or Anyone in your domain. This is the coarsest filter and the only one Google enforces for you.
  2. Identity check inside doGet/doPost. Once a request reaches your code, read who sent it with Session.getActiveUser().getEmail() and decide whether that person is allowed.
  3. Authentication token in the URL. When callers are anonymous — a webhook, a script with no Google identity — a shared secret in the query string is the only thing standing between them and your logic.
LayerFilters outWorks for anonymous callers?Enforced by
Deployment settingUsers without the right Google account/domainNoGoogle
Identity checkAuthenticated users not on your listNoYour code
Token authAnyone without the secretYesYour code

The deployment setting and the identity check both rely on a Google sign-in, so they are useless against a caller that has no Google account. That gap is what token auth fills.

Email allowlist

If every legitimate caller signs in with a Google account, an allowlist is the simplest reliable control. Keep the list of permitted addresses in code (or in Script Properties for easier editing) and reject everyone else before doing any real work.

// The set of accounts allowed to reach the admin area.
const ALLOWED = ['[email protected]'];

function doGet(e) {
  // getActiveUser() only returns an email when the deployment runs as the
  // user AND the caller is signed in to a Google account in the same domain.
  const email = Session.getActiveUser().getEmail();

  // Fail closed: an unknown or empty email gets nothing.
  if (!ALLOWED.includes(email)) {
    return HtmlService.createHtmlOutput('Forbidden');
  }

  // Only allowlisted users reach this line.
  return HtmlService.createHtmlOutput('Admin area');
}

Two things make this work. First, the check runs before any sensitive logic, so a rejected caller never triggers a side effect. Second, it fails closed — an empty or unrecognised email is treated as forbidden, not as a special case to wave through.

Be aware that getActiveUser().getEmail() can return an empty string for callers outside your domain even when they are signed in. If your audience spans multiple domains, prefer token auth or store identities you trust explicitly.

Token auth (for anonymous)

When the caller has no Google identity — a third-party webhook, a cron job, a script running under a service account — there is no email to check. Instead, issue a long random secret and require it on every request.

// The shared secret lives in Script Properties, never in the code itself.
const TOKEN = PropertiesService.getScriptProperties().getProperty('PUBLIC_TOKEN');

function doGet(e) {
  // Reject any request whose 't' parameter does not match the secret.
  if (e.parameter.t !== TOKEN) {
    return HtmlService.createHtmlOutput('Forbidden');
  }

  // Past this point the caller has proven they hold the secret.
  return HtmlService.createHtmlOutput('Public-ish content');
}

Treat the token like a password. Generate it with real randomness, make it long (32+ characters), and store it in Script Properties so it never appears in your source or version history. If it leaks, rotate it: change the property value and hand the new token to legitimate callers.

A token in a URL query string is visible in server logs and browser history, so it is “good enough” rather than airtight. For anything genuinely sensitive, prefer doPost with the token in the request body, and keep the deployment set to the narrowest audience that still works.

Watch out for

  • “Execute as: User” combined with Access “Anyone” forces a Google authorisation dance on every visit, and getActiveUser() still may not return an email — a confusing combination to debug. Decide deliberately whether the app runs as you or as the visitor.
  • Sharing an “Anyone with the link” web-app URL is functionally public. Assume the URL will leak — into chat history, a screenshot, a referrer header — and never rely on its obscurity as a security measure.
  • Returning a different message for “forbidden” versus “not found” leaks information. Keep rejection responses identical and uninformative.
  • Forgetting that every new deployment can get a new URL. Use “Manage deployments” to update the existing deployment in place so callers’ URLs stay valid.
  • Putting the access check after the work instead of before it. The check must be the first thing doGet/doPost does, or a rejected caller can still trigger side effects.