This kit gives a Google Apps Script web app role-based access control. Before you rely on it, it is important to understand exactly what it protects, what it trusts and the deployment settings it depends on. That last point is the one that can potentially bite, so it has its own section.
The threat model in brief
This kit decides what a known user is allowed to do. It does not decide who the user is and it cannot verify identity on its own. It trusts Google to answer that, and Google only answers reliably under specific deployment settings.
Authentication vs authorisation
This kit is authorisation, not authentication. In this context, that difference really matters.
- Authentication answers “who is making this request?”. The kit does not do this. It asks Google, via
Session.getActiveUser().getEmail(). - Authorisation answers “is this user allowed to do this?”. This is the whole of what the kit provides: roles, a role hierarchy and the guards that enforce them.
Everything downstream rests on the email that Google hands back. If that value is wrong or empty the authorisation layer is reasoning about the wrong person. So the entire security scope reduces to one question: can you trust the identity signal (the Workspace login) in your deployment?
The trust anchor
Everything depends on a crucial, single line of code:
const email = Session.getActiveUser().getEmail();
Per Google’s own documentation, this returns a blank string when “security policies do not allow access to the user’s identity”. The case that matters for a web app:
Under
executeAs: USER_DEPLOYING(“execute as me”),getActiveUser().getEmail()returns an empty string for any visitor outside the deploying developer’s Workspace domain (a consumer@gmail.comaccount, or a different Workspace domain). It is reliable only when the script is run by the developer or by a user in the same Workspace domain.
This is not a bug. It is a privacy control. But it means the kit can only identify users it is allowed to identify, and that set is decided by how you deploy.
Deployment safety matrix
This is the most important section in the repo. Read it before you deploy anything real.
The matrix below is for the default sheet-backed mode, where the kit reads a private auth sheet owned by you (the deployer).
executeAs | access | Who can be identified | Verdict |
|---|---|---|---|
USER_DEPLOYING | DOMAIN (same Workspace domain) | Everyone in your domain, reliably | ✅ Intended use. Identity is reliable and the deployer reads the private auth sheet. |
USER_DEPLOYING | MYSELF | Only you | ✅ Fine for development and testing. |
USER_DEPLOYING | ANYONE (any Google account) | Domain users only. External users return "" | ⚠️ Unsafe for authenticated roles. External visitors are unidentifiable and must fail closed. |
USER_DEPLOYING | ANYONE_ANONYMOUS | Nobody is identified | ⛔ Authenticated roles impossible. Use only for a genuinely public guest tier. |
USER_ACCESSING | any | The visitor, reliably | ⚠️ Identity works, but the script now reads the auth sheet as the visitor, so every user would need read access to your private auth sheet. Breaks the private-store model. Acceptable only for domain/allowlist mode, which reads no sheet. |
Intended default configuration: deploy USER_DEPLOYING + DOMAIN inside your own Workspace domain. That is the configuration where identity is reliable (as far as the kit trusts Google) and the auth sheet stays private. It is also exactly the configuration the Workspace projects using this kit run in.
If you need to serve users outside a single Workspace domain with verified identity, Apps Script’s Session API cannot do it under “execute as me”. You have outgrown this kit. See When to outgrow this kit.
Fail closed by default
The kit denies anyone it cannot positively authorise. Concretely:
- An empty email from
getActiveUser()is treated as no identity and access is refused. The kit never caches or acts on a blank identity. - A user who is not found in the auth store gets no role and is refused.
- A user whose row is marked inactive is refused.
The deliberate exception is open enrolment, behind the explicit ALLOW_OPEN_ENROLMENT flag and documented in full, including the empty-email caveat above, because open enrolment plus an unidentifiable visitor is the worst case.
Where failing open is correct
One of the Workspace projects this kit has grown alongside is a student-facing classroom app. It fails open: an unknown visitor is auto-assigned a working
studentrole. That is the correct behaviour in this case – anyone with the link should be able to access the app (at the lowest access tier). As a default copied into other contexts it would potentially be dangerous. That is why the kit ships closed and makes the open behaviour an explicit choice.
Open enrolment also never triggers on an error. A failed read of the auth sheet is a refusal (Authentication service error), not an enrolment: only a user the kit has positively confirmed absent from a sheet it could read is granted the enrolment role. Without that guarantee a transient sheet failure would hand the enrolment role to everyone, including a deliberately deactivated user whose row could not be read.
Every public function is callable from the browser
Apps Script exposes every top-level server function in the project to google.script.run, and (as per Apps Script convention) only a trailing underscore hides one (getUserFromSheet_). A leading underscore (the usual JS convention) has no effect.
The kit is written to that rule: internal functions end in an underscore, and every public function either guards itself with requireAuth() or reveals nothing about anyone but the caller. Your own server files must follow the same rule. Write every top-level function assuming a hostile caller: guard it, or name it myHelper_ so the platform hides it. (The editor’s function dropdown hides them too; when one needs to be run by hand, give it a public owner-guarded wrapper, as the kit does with SETUP_createAuthSheet() and DEBUG_auth().)
The cache bug (a real bug, documented)
⚠️ Never cache identity in
PropertiesService.getUserProperties()underexecuteAs: USER_DEPLOYING. That store is scoped to the deployer, not the visitor, so it is a single bucket shared by every user. A Workspace project running an early prototype of this kit cached a resolved identity there and a user silently inherited an admin’s role the moment the admin opened the app. This kit keeps no cache and resolves identity fresh on every call.
This surfaced in pre-deployment testing. Because the failure is subtle it is worth making a note of. The root cause is a property of the exact deployment this kit suggests and the lesson is baked into the design.
What happened. A teacher account acquired full admin privileges, with no attacker and no exploit, simply because an admin opened the app in another browser. The teacher’s UI began showing admin surfaces and admin-only server endpoints started accepting the teacher’s calls.
The root cause: a property store that is not per user. The prototype cached each resolved identity in PropertiesService.getUserProperties() to avoid re-reading the auth sheet on every call. Apps Script documents that store as readable and writable “only by the current effective user of the script”, and the effective user is decided by the web app’s executeAs setting:
executeAs: USER_ACCESSING– the effective user is the visitor, so user properties are genuinely per visitor.executeAs: USER_DEPLOYING– the effective user is the deployer, so user properties collapse to a single bucket shared by every visitor.
This kit recommends USER_DEPLOYING (see the deployment safety matrix), because that is the only configuration where Session.getActiveUser().getEmail() is reliable across a whole domain. Under that exact setting getUserProperties() is not per user. Every visitor read and wrote the same cached identity.
Why it escalated. Whichever user resolved last wrote their record into the shared slot, and every later call read that record verbatim and skipped re-validation. An admin loading the page left an admin record in the slot, and the next call from any user, of any role, came back as that admin. The role guard consulted only the cached record and never cross-checked it against the live session email, so it waved the teacher through. It was deterministic rather than a race: last writer wins, and the win persists until the slot is next overwritten.
The design decision. This kit removes the cache entirely. getCurrentUserAuth() resolves identity from Session.getActiveUser().getEmail() and the auth store on every call. The auth-sheet read is a single getValues() call and is cheap at the scale this kit targets, so there is no real cost and a whole class of cross-user leakage is ruled out by construction. If you reintroduce a cache, key it on the live session email, re-validate on every read and remember that under USER_DEPLOYING you are caching into a store shared by all users.
The secondary lesson: an empty email is a hard stop. The same shared store amplified a related hazard. When Session.getActiveUser().getEmail() returns "" (its documented behaviour for visitors the deployment cannot identify) a blank identity must never be cached, returned or treated as a user. This kit refuses on an empty email before doing anything else, in both auth modes. A blank email is never a valid identity and never a key.
What each defence layer actually protects
Of the kit’s three guard layers – server route guard, client navigation guard and conditional rendering – only the server route guard (requireAuth()) is a security control: it runs on the server, the user cannot bypass it and it throws before any privileged work or render happens. The client navigation guard is UX and conditional rendering is tidiness; both run in the browser, are trivially bypassable and protect nothing on their own. The data behind a hidden control is protected only by the server guard. The three layers are laid out in the Architecture page of the docs.
When to outgrow this kit
Reach for a real identity platform when any of these are true:
- You need to authenticate users from outside a single Workspace domain (consumer accounts, multiple unrelated organisations) with verified identity.
- You need session tokens, password or passwordless auth, MFA, or device trust.
- You need audit-grade authentication logs.
- You need authorisation decisions to survive the app being deployed in a configuration where
Sessioncannot identify the caller.
In those cases use Google Identity Platform, Firebase Authentication or Google Sign-In. This kit exists only for the case where they are not available to you.
Reporting a vulnerability
Do not open a public issue or pull request for an undisclosed vulnerability. Public activity on the repository is visible to everyone, so keep the details private until a fix is available.
Report it privately through GitHub: open the repository’s Security tab and choose Report a vulnerability. If you cannot use GitHub, email security@lundie.io.
Include where possible:
- the deployment settings in play (
executeAs,accessand the auth mode) - steps to reproduce, or a description of the flaw
- the impact you believe it has