Script Properties for bootstrapper config

TL;DR — Stop hardcoding API keys and sheet IDs in Apps Script. Use Script Properties (and Document Properties) with small get/set helpers so solo builders can ship config without pasting secrets into every file.

Hardcoding an API key in the script editor feels fine… until you share the project, clone it for another client, or paste the same file into three workbooks and realize the secret is now everywhere. Script Properties (and Document Properties) are the boring fix. Here's how we actually store bootstrapper config so you stop hunting string literals at 11pm.

TL;DR

  • Put secrets and environment-ish config in PropertiesService — not in source, not in a visible Settings tab by default.
  • Prefer Script Properties for project-wide keys (API tokens, default sheet names, feature flags).
  • Prefer Document Properties when the value belongs to this spreadsheet (bound script, per-file sheet IDs, per-client endpoints).
  • Wrap get/set in tiny helpers so typos and missing keys fail loud.
  • A Settings sheet is fine for non-secret knobs stakeholders edit — never for tokens.

Using the Apps Script editor a lot? NitroGAS drops free themes & snippets right into script.google.com — optional Co-Pilot when you want a boost.

The failure mode

You've shipped a "quick" integration. It works. Then one of these hits:

  • You email the .gs file (or share the container) and the Slack bot token goes with it
  • Client B's copy still calls Client A's webhook because the URL is baked into Code.gs
  • Someone "cleaned up" a Settings tab and wiped the API key that lived in cell B2 in plain text
  • A teammate forks the script, commits it to a shared Drive folder, and now the key is in version history forever

We've been that person grepping for sk_live_ across Drive. The code wasn't wrong — the storage was. Secrets and environment config don't belong next to function onOpen().

What not to do

  1. Hardcode keys in sourceconst API_KEY = 'abc…' in a .gs file. Fast today, leaky tomorrow, painful when you rotate.
  2. Secrets on a Settings tab — great for "default batch size" or "notify email"; terrible for tokens. Anyone with edit access can see (and copy) them. Exports and copies take the tab along.
  3. One giant undocumented blob — stuffing JSON into a single property with no helper, no key list, and no idea which script owns which key. You'll overwrite yourself.

Script vs Document Properties (pick on purpose)

Store Lives with Use when
PropertiesService.getScriptProperties() The Apps Script project API keys, shared defaults, flags that should follow the code
PropertiesService.getDocumentProperties() The container spreadsheet (bound scripts) Per-file sheet IDs, client-specific endpoints, watermarks tied to this workbook
PropertiesService.getUserProperties() The signed-in user Rare for bootstrapper tools — personal prefs only

Rule of thumb: if cloning the script to another file should keep the value, use Script Properties. If cloning the spreadsheet should keep the value (or each file needs its own), use Document Properties.

User Properties almost never belong in client sync jobs — they surprise the next person who runs the script under a different Google account.

When Properties beat a Settings tab

Use a Settings sheet when:

  • A non-developer needs to change the value without opening the script editor
  • The value isn't secret (notification email, chunk size, "Active" flag)
  • You want the config visible in the workbook for audits

Use Properties when:

  • It's a secret (API key, webhook URL with token, service account-ish material you shouldn't paste in cells)
  • You want config that survives tab renames / accidental deletes
  • Multiple functions share the same keys and you don't want stringly-typed getRange('B2') everywhere

Many solid bootstraps use both: Properties for secrets, a thin Settings tab for the knobs humans touch. Don't force one pattern to do the other's job.

Pasteable helpers

Drop these into the editor. Skim once, then paste — swap key names to match your project.

Step A — decide Script vs Document for each key
Step B — set values once (manual run, or a one-time setup function)
Step C — read through helpers everywhere else
Step D — never log the raw secret in production sheets

/** * Small PropertiesService helpers for bootstrapper config. * Script Properties = project-wide. Document Properties = this spreadsheet. */ var CONFIG_KEYS = { API_KEY: 'API_KEY', WEBHOOK_URL: 'WEBHOOK_URL', TARGET_SHEET_ID: 'TARGET_SHEET_ID', // document-scoped example BATCH_SIZE: 'BATCH_SIZE' // non-secret; could also live on a Settings tab }; function getScriptProp_(key) { var value = PropertiesService.getScriptProperties().getProperty(key); if (value === null || value === '') { throw new Error('Missing Script Property: ' + key + '. Run setupScriptConfig_() once.'); } return value; } function setScriptProp_(key, value) { PropertiesService.getScriptProperties().setProperty(key, String(value)); } function getDocProp_(key) { var value = PropertiesService.getDocumentProperties().getProperty(key); if (value === null || value === '') { throw new Error('Missing Document Property: ' + key + '. Run setupDocumentConfig_() once.'); } return value; } function setDocProp_(key, value) { PropertiesService.getDocumentProperties().setProperty(key, String(value)); } /** One-time (or rare) setup — run manually from the editor. */ function setupScriptConfig_() { // Replace placeholders. Do NOT commit real secrets to git / shared Drive copies of source. setScriptProp_(CONFIG_KEYS.API_KEY, 'paste-your-key-here'); setScriptProp_(CONFIG_KEYS.WEBHOOK_URL, 'https://example.com/hooks/your-endpoint'); setScriptProp_(CONFIG_KEYS.BATCH_SIZE, '500'); Logger.log('Script Properties saved. Clear this function body or remove placeholder strings after running.'); } /** Bound-script setup — sheet IDs / per-file values. */ function setupDocumentConfig_() { setDocProp_(CONFIG_KEYS.TARGET_SHEET_ID, SpreadsheetApp.getActiveSpreadsheet().getId()); Logger.log('Document Properties saved for this spreadsheet.'); } /** Example consumer — no secrets in source. */ function callExternalApi() { var apiKey = getScriptProp_(CONFIG_KEYS.API_KEY); var url = getScriptProp_(CONFIG_KEYS.WEBHOOK_URL); var response = UrlFetchApp.fetch(url, { method: 'get', headers: { Authorization: 'Bearer ' + apiKey }, muteHttpExceptions: true }); if (response.getResponseCode() >= 400) { // Log status, not the key throw new Error('API call failed with HTTP ' + response.getResponseCode()); } return JSON.parse(response.getContentText()); } /** Optional: dump *keys* (not values) so you can see what's configured. */ function listConfigKeys() { var scriptKeys = Object.keys(PropertiesService.getScriptProperties().getProperties()); var docKeys = Object.keys(PropertiesService.getDocumentProperties().getProperties()); Logger.log('Script keys: ' + JSON.stringify(scriptKeys)); Logger.log('Document keys: ' + JSON.stringify(docKeys)); }

A few notes on what this is doing:

  • Fail loud on missing keys — a thrown Missing Script Property: API_KEY beats a mysterious empty Authorization header.
  • CONFIG_KEYS map — one place to rename keys; fewer magic strings.
  • Setup functions are deliberate — you run them once from the editor (or a locked admin menu). Day-to-day code only reads.
  • Don't Logger.log the secret — log key names, HTTP status, sheet names. Assume Executions logs get shared in screenshots.

You can also set Script Properties in the Apps Script UI: Project Settings → Script Properties. Same store; helpers still worth having so reads stay consistent.

Rotating and sharing without drama

When a key leaks or an employee leaves:

  1. Rotate the secret at the provider first.
  2. Update the Property (UI or setScriptProp_).
  3. Re-run a smoke test — don't redeploy source just to change a token.

When you hand a template to another client:

  • Ship empty Properties (or a setup checklist), not Client A's values.
  • Keep Document Properties for anything that must differ per spreadsheet.
  • If you must share source in plain text, grep for setProperty / placeholder leftovers before you hit send.

Minimal test plan

  1. Run setupScriptConfig_() with a throwaway key on a scratch project.
  2. Call callExternalApi() (or your real consumer) and confirm it reads the Property — not a leftover hardcoded string.
  3. Delete the Property in Project Settings; confirm the next run throws Missing Script Property… instead of failing silently.
  4. For bound scripts: set a Document Property, make a File → Make a copy, confirm the copy has its own Document Properties (Script Properties still follow the script project depending on how you copied — verify, don't assume).

Keep the helpers handy

If you're tired of re-pasting the same get/set wrappers into every client file, NitroGAS keeps patterns like this as snippets inside the Apps Script editor — Co-Pilot's there if you need to adapt them. Free extension; Co-Pilot optional. Either way, the helpers above stand on their own.

Closing checklist

  • No API keys / webhook tokens live in .gs source or on a shared Settings tab
  • Script vs Document Properties chosen on purpose (project-wide vs this file)
  • get/set helpers fail loud when a key is missing
  • Setup is a deliberate one-time run (or Project Settings UI) — day-to-day code only reads
  • You can rotate a secret without editing business logic
  • Logs never print the raw secret

That's it. Put secrets in Properties, put human knobs where humans can see them, and stop treating Code.gs like a password manager.

Happy Coding!