appscript.dev
Automation Advanced

Build an API-key vault and rotation system

Manage Northwind credentials securely at scale — centralised storage, scheduled rotation.

Published Dec 22, 2025

Northwind’s scripts talk to a dozen third-party APIs, and every one needs a key. Over a couple of years those keys end up scattered — a few in Script Properties, one hard-coded in an old project, two in a shared doc nobody admits to. When a provider forces a rotation, nobody is sure which scripts will break, and an expired key only ever announces itself as a failed job at the worst possible moment.

This script gives Northwind one small vault. Every key is stored in one place with an expiry date attached, fetched through a single getKey call, and watched by a scheduled check that emails ops before anything lapses. It will not stop a provider revoking a key, but it does mean the team finds out two weeks early instead of from a 401.

What you’ll need

  • An Apps Script project to act as the vault — it can be standalone, or the shared library every other Northwind script imports.
  • The keys themselves, ready to load. The script writes them into Script Properties, so they are never typed into source files — see Store API keys and secrets securely.
  • A monitored mailbox at [email protected] for expiry warnings.
  • Edit access for whoever loads or rotates keys; everyone else only needs to call getKey.

The script

// Every vault entry is stored under this prefix so the expiry sweep can
// tell vault keys apart from other Script Properties.
const VAULT_KEY_PREFIX = 'key:';

// Where expiry warnings are sent.
const OPS_EMAIL = '[email protected]';

// Default lifetime for a new key, in days.
const DEFAULT_DAYS_VALID = 90;

// How many days before expiry the scheduled check starts warning.
const WARN_WINDOW_DAYS = 14;

// One day in milliseconds — used for all the date arithmetic.
const DAY_MS = 86400000;

/**
 * Stores a key in the vault with an expiry date attached.
 *
 * @param {string} name        A short label, e.g. 'stripe' or 'sendgrid'.
 * @param {string} value       The secret itself.
 * @param {number} daysValid   How long the key should be considered valid.
 */
function setKey(name, value, daysValid = DEFAULT_DAYS_VALID) {
  if (!name || !value) throw new Error('setKey needs both a name and a value.');

  const props = PropertiesService.getScriptProperties();
  const expiresAt = new Date(Date.now() + daysValid * DAY_MS).toISOString();

  // Store value and expiry together as one JSON blob.
  props.setProperty(VAULT_KEY_PREFIX + name, JSON.stringify({ value, expiresAt }));
  Logger.log('Stored "' + name + '", expires ' + expiresAt.slice(0, 10));
}

/**
 * Fetches a key by name. If the key has already expired it still returns
 * the value — so the calling script does not break mid-job — but fires an
 * email so ops knows to rotate it now.
 *
 * @param {string} name  The label used when the key was stored.
 * @returns {string}     The secret value.
 */
function getKey(name) {
  const raw = PropertiesService.getScriptProperties()
    .getProperty(VAULT_KEY_PREFIX + name);

  // A missing key is a hard error — fail loudly rather than return undefined.
  if (!raw) throw new Error('No such key in the vault: ' + name);

  const { value, expiresAt } = JSON.parse(raw);

  // Past its expiry date — hand back the value but raise the alarm.
  if (new Date(expiresAt) < new Date()) {
    GmailApp.sendEmail(
      OPS_EMAIL,
      'Expired API key: ' + name,
      'The vault key "' + name + '" expired on ' + expiresAt.slice(0, 10) +
        '. Rotate it immediately with setKey().'
    );
  }
  return value;
}

/**
 * Sweeps the whole vault and logs every key due to expire within the
 * warning window. Meant to run on a daily time-driven trigger.
 */
function checkAllExpiry() {
  const props = PropertiesService.getScriptProperties().getProperties();
  const cutoff = Date.now() + WARN_WINDOW_DAYS * DAY_MS;
  const dueSoon = [];

  // Walk every property; skip anything that is not a vault entry.
  for (const [k, raw] of Object.entries(props)) {
    if (!k.startsWith(VAULT_KEY_PREFIX)) continue;

    const { expiresAt } = JSON.parse(raw);
    if (new Date(expiresAt).getTime() < cutoff) {
      const name = k.slice(VAULT_KEY_PREFIX.length);
      dueSoon.push(name + ' (' + expiresAt.slice(0, 10) + ')');
      Logger.log('Rotate soon: ' + name);
    }
  }

  // One digest email beats one email per key.
  if (dueSoon.length) {
    GmailApp.sendEmail(
      OPS_EMAIL,
      dueSoon.length + ' API key(s) due for rotation',
      'These vault keys expire within ' + WARN_WINDOW_DAYS + ' days:\n\n- ' +
        dueSoon.join('\n- ')
    );
  }
}

How it works

  1. setKey takes a name, a value and an optional lifetime. It builds an ISO expiry date and stores the value and expiry together as a single JSON blob under the key: prefix. Storing them together means a key can never lose its expiry date.
  2. getKey is what every other Northwind script calls. It reads the blob, throws a clear error if the key does not exist, and parses out the value.
  3. If the key is already past its expiry date, getKey still returns the value — so a running job is not broken mid-flight — but emails ops so the rotation happens straight away.
  4. checkAllExpiry is the scheduled sweep. It loads every Script Property, ignores anything without the vault prefix, and collects keys expiring inside the 14-day warning window.
  5. Rather than one email per key, it sends a single digest listing every key due for rotation, with its expiry date.

Example run

Load three keys, each with a different lifetime:

setKey('stripe', 'sk_live_...', 90);
setKey('sendgrid', 'SG.xxxx', 30);
setKey('maps', 'AIza...', 365);

Two weeks before the SendGrid key lapses, the daily checkAllExpiry sweep produces a digest email:

FieldValue
To[email protected]
Subject1 API key(s) due for rotation
BodyThese vault keys expire within 14 days: - sendgrid (2026-01-22)

A normal call elsewhere in the codebase stays a one-liner:

const stripeKey = getKey('stripe');

Trigger it

checkAllExpiry is the only function that needs scheduling. setKey and getKey run on demand.

  1. In the Apps Script editor, open Triggers (the clock icon).
  2. Click Add Trigger and choose checkAllExpiry.
  3. Set the event source to Time-driven, a Day timer, and an early morning slot.
  4. Save and approve the authorisation prompt.

After that, ops gets a heads-up two weeks before any key expires, and a louder warning the moment one slips through.

Watch out for

  • Script Properties are not encrypted at rest. They are private to the project and its editors, which is a real boundary — but anyone with edit access can read every key. Keep the vault project’s sharing list short.
  • getKey deliberately returns an expired key rather than throwing. That keeps jobs alive, but it means an ignored warning email lets a dead key linger. Treat the expiry emails as action items, not noise.
  • There is a 9KB limit per property value and 500KB total across the store. Keys are tiny, so this is rarely a problem, but do not stuff large secrets or certificates into the vault.
  • Rotation is manual on purpose — the script tells you when, it does not call the provider to mint a new key. Few providers expose a rotation API, and an automated swap that half-fails is worse than a calendar reminder.
  • If you make the vault a shared library, every consuming script must re-authorise after a permissions change. Plan rollouts so a key fetch never fails silently behind a stale authorisation.

Related