
Handling State and Authentication in Google Apps Script Apps
Hey everyone, welcome back to MageSheet.
To wrap up our first pillar focus on Apps Script Web Apps, we need to talk about the elephant in the room: State Management and Authentication.
When you build a Secure Client Portal or an Inventory web app, your users expect a modern Single Page Application (SPA) experience. They log in once, the screen transitions to a dashboard, and they click around without the browser hard-loading a new URL.
But Apps Script's HtmlService is stateless. Every time the frontend calls google.script.run.getSomeData(), the backend spins up completely fresh. It has no memory of the user who just logged in two seconds ago.
So how do we keep a user logged in, maintain their state securely, and prevent unauthorized data access?
The Fallacy of Client-Side Authentication
A common, dangerous mistake developers make in Apps Script is trusting the frontend.
The Bad Architecture:
- User enters PIN on frontend.
- Frontend calls
google.script.run.verifyPin(pin). - Backend returns
true. - Frontend saves a JavaScript variable:
let isLoggedIn = true; let currentUserId = 'C-101'; - Frontend calls
google.script.run.fetchInvoices(currentUserId).
Why is this terrible? Because anyone can open Google Chrome Developer Tools, type google.script.run.fetchInvoices('C-102') into the console, and steal another client's data! The backend essentially says, "I trust whoever the frontend says it is computing for."
The Proper Solution: Session Tokens
To build a secure SPA in Apps Script, we must generate a temporary Session Token on the backend during login, hand it to the frontend, and require the frontend to pass that token back on every single subsequent request.
We can store these active sessions in two ways:
- A hidden "Active Sessions" sheet.
- The
CacheService(Much faster and cleaner!).
Let's look at how to implement secure, server-side verified sessions using Google Apps Script's built-in CacheService.
1. The Secure Login Endpoint
When the user enters their credentials, the backend validates them, generates a random unique token, stores that token in the Cache (linked to the User ID), and returns the token to the frontend.
// Code.gs
function loginSecurely(email, pin) {
// Assume isValidUser() checks the Google Sheet and returns the internal user ID
const userId = isValidUser(email, pin);
if (!userId) {
return { success: false, error: "Invalid login" };
}
// 1. Generate a secure, random Session Token
const sessionToken = Utilities.getUuid();
// 2. Store the token in the Apps Script Cache for 2 hours (7200 seconds)
const cache = CacheService.getScriptCache();
cache.put(sessionToken, userId, 7200);
// 3. Return the token to the frontend
return { success: true, token: sessionToken };
}
2. Guarding the Data Endpoints
Now, let's rewrite our fetchInvoices function. We no longer ask the frontend who they are. We only ask for their token.
// Code.gs
function fetchInvoices(sessionToken) {
// 1. Verify the token exists in the cache
const cache = CacheService.getScriptCache();
const userId = cache.get(sessionToken);
// 2. If the token is invalid or expired, reject the request completely.
if (!userId) {
return { success: false, error: "Session expired or invalid. Please log in again." };
}
// 3. We know EXACTLY who the user is now, verified securely by the server.
// We fetch only their data.
const allInvoices = getInvoicesFromSheet(); // helper function
const userInvoices = allInvoices.filter(inv => inv.clientId === userId);
return { success: true, data: userInvoices };
}
3. Managing State on the Frontend
Now, your Index.html frontend just needs to hold onto that token and pass it along with every API call it makes via google.script.run.
<script>
// The global state of our SPA
let APP_STATE = {
sessionToken: null
};
// Called when the user clicks Login
function handleLogin() {
const email = document.getElementById('email').value;
const pin = document.getElementById('pin').value;
google.script.run
.withSuccessHandler(function(res) {
if(res.success) {
// Store the token in state
APP_STATE.sessionToken = res.token;
loadDashboard();
} else {
alert(res.error);
}
})
.loginSecurely(email, pin);
}
// Called to load the actual data
function loadDashboard() {
// We pass the token, NOT the user ID
google.script.run
.withSuccessHandler(function(res) {
if(res.success) {
renderInvoices(res.data);
} else {
// Token expired, send them back to login screen
showLoginScreen();
}
})
.fetchInvoices(APP_STATE.sessionToken);
}
</script>
Session Expiry and Refresh Patterns
The 2-hour TTL we set on the cache is a fixed expiry. Once two hours pass, the user is logged out — even if they were actively using the app five seconds ago. This annoys valid users and is only marginally safer than a longer window. A better pattern is the sliding window: every authenticated request extends the TTL by another two hours.
function fetchInvoices(sessionToken) {
const cache = CacheService.getScriptCache();
const userId = cache.get(sessionToken);
if (!userId) {
return { success: false, error: 'Session expired' };
}
// Extend the session — sliding window
cache.put(sessionToken, userId, 7200);
// ... fetch and return data
}
Active users stay logged in indefinitely; inactive sessions die after two hours of silence.
For sensitive apps — billing, HR, compliance dashboards — you usually want a second cap: an absolute maximum session lifetime regardless of activity. Store the session creation time alongside the token:
cache.put(sessionToken, JSON.stringify({
userId,
createdAt: Date.now()
}), 7200);
On every check, compare Date.now() - createdAt against your absolute ceiling (typically 8 or 24 hours). If exceeded, force re-login regardless of TTL.
Multi-Device Sessions and Logout Everywhere
A single CacheService entry per session token works fine until a user logs in from a second device. Our loginSecurely generates a fresh UUID every call, so device A and device B each get their own token, both stored in the cache, both valid. Simultaneous sessions are usually what you want.
The complication is "log out everywhere" — the user clicks a button on device A and expects every session, on every device, to be invalidated immediately. Cache eviction does not solve this; the device B token is still valid.
The fix is a per-user revocation timestamp stored in a Users sheet and checked on every request:
function fetchInvoices(sessionToken) {
const data = JSON.parse(CacheService.getScriptCache().get(sessionToken) || 'null');
if (!data) return { success: false };
const user = getUserRecord(data.userId);
if (data.createdAt < user.revokedAt) {
return { success: false, error: 'Session revoked' };
}
// ... proceed
}
function logoutEverywhere(sessionToken) {
const data = JSON.parse(CacheService.getScriptCache().get(sessionToken));
setUserRevokedAt(data.userId, Date.now());
}
This adds a Sheet read per authenticated request. For most apps that is fine; if performance matters, cache the revocation timestamp itself for 60 seconds — slightly slower revocation in exchange for far fewer Sheet reads.
Role-Based Access Control (RBAC)
Authentication answers "who are you?" Authorization answers "what are you allowed to do?" Token-based auth gives you the first; you have to layer on the second yourself.
The cleanest pattern is to store roles in the Users sheet alongside the user record and enforce on every endpoint. Roles can be flat (admin | editor | viewer) or scoped (finance.read, finance.write). Start with flat — most apps do not need the complexity of scopes.
A requireRole helper centralizes the check:
function requireRole(sessionToken, allowedRoles) {
const data = JSON.parse(
CacheService.getScriptCache().get(sessionToken) || 'null'
);
if (!data) throw new Error('Not authenticated');
const user = getUserRecord(data.userId);
if (!allowedRoles.includes(user.role)) {
throw new Error('Forbidden');
}
return user;
}
function deleteInvoice(sessionToken, invoiceId) {
const user = requireRole(sessionToken, ['admin', 'finance']);
// ... actual delete logic, using user.id for audit trail
}
Two production rules:
- Default deny. If a function does not call
requireRole, treat it as a bug. We have seen apps where one helper bypassed the auth layer because the original developer forgot — and a viewer-tier user could delete records by calling it directly viagoogle.script.run. - No role checking on the frontend alone. Hide the delete button from viewers in the UI, but never trust that as your security boundary. The backend
requireRoleis the only thing that matters.
Audit Logging for Sensitive Operations
For any operation that affects money, inventory, customer data, or permissions, you need a trail. Without one, the first time someone disputes "I didn't delete that record," you have nothing to show them.
A hidden audit_log sheet with append-only writes is enough for most cases:
timestamp | userId | action | target | metadata
2026-04-30 14:22:01 | U-101 | invoice.delete | INV-9912 | { reason: "duplicate" }
2026-04-30 14:25:33 | U-203 | user.role.change| U-345 | { from: "viewer", to: "editor" }
Wrap every mutating endpoint in a one-line audit call:
function deleteInvoice(sessionToken, invoiceId, reason) {
const user = requireRole(sessionToken, ['admin', 'finance']);
doDelete(invoiceId);
appendAudit(user.id, 'invoice.delete', invoiceId, { reason });
}
Three production rules: never let a user edit their own audit entries (the sheet must be admin-write-only), never log secrets or full payloads (PII, tokens, passwords are off-limits), and rotate the sheet quarterly into an audit_archive_2026Q2 tab so the live log stays small enough to query fast.
Summary
By leveraging CacheService.getScriptCache() and UUIDs, you can build a highly secure, stateful Single Page Application architecture directly on top of Google Apps Script.
There are no databases to spin up, no Redis clusters to manage, and no monthly server fees. Google handles the cache eviction automatically after the time limit you set.
If your enterprise relies heavily on Google Workspace, don't let data security hold you back from building custom tools. With the right architecture, Apps Script is just as secure and robust as traditional web stacks.
Need help building complex B2B applications, or integrating secure workflows between your Magento storefront and your Google Workspace? Contact MageSheet today to discuss custom consulting solutions.
Frequently Asked Questions
How long should my Apps Script session tokens last?
For most internal tools, 2 hours of inactivity is a reasonable balance — short enough to limit damage from a stolen token, long enough not to annoy valid users. For sensitive apps (HR, billing, compliance dashboards) drop to 30 minutes. For low-risk read-only dashboards, 8 hours is fine. The non-obvious rule: idle timeout (sliding window) is more user-friendly, but absolute timeout is safer. Production systems usually use both — idle 30 minutes plus absolute 8 hours, whichever fires first.
What happens if a user closes their browser without logging out?
The session token lives in CacheService until its TTL expires (two hours in our pattern). When the user reopens the browser, the in-memory state is gone and they are asked to log in again. The server-side session also expires on its own, so even if someone steals the token from the network during that window, the damage window is bounded. This is the right behavior — never persist session tokens to disk on the client side.
Can I store session tokens in localStorage instead of in-memory state?
Technically yes, but you should not. localStorage is accessible to any JavaScript on the page (including injected XSS payloads), so a single XSS bug exfiltrates every session for every user. The standard alternative — httpOnly cookies — is impossible in the Apps Script web app sandbox because google.script.run does not expose cookies. The compromise: keep tokens in JavaScript memory for the lifetime of the tab, accept that page refresh equals new login, and rely on the short server-side TTL to bound any exposure.
How do I implement 'logout from all devices' in Apps Script?
Add a revokedAt timestamp to a per-user record in your Users sheet, and check it on every authenticated request. When the user clicks 'logout everywhere,' set the timestamp to now. Any cached token whose creation time is before that timestamp is rejected, regardless of its TTL. This adds one Sheet read per request — cache the timestamp itself in CacheService for 60 seconds if performance matters.
Is CacheService safe enough for storing auth tokens, or should I use a real database?
CacheService is fine for short-lived session tokens (2–8 hours) on small-to-medium teams (under ~500 active users). The known limitations: 6-hour absolute TTL ceiling, 100KB per key, no cross-region replication, occasional eviction under memory pressure. For longer-lived sessions or larger teams, write tokens to a hidden Sheet with the same schema (token, userId, expiresAt, revokedAt) and check both layers — CacheService for the fast path, Sheet as durable backup.




