appscript.dev
Automation Advanced Drive

Build an expiring secure-download generator

Issue time-limited Northwind links via a web app — token in URL, server-side check.

Published Oct 23, 2025

When Northwind sends a customer a one-off file — a signed contract, an export of their data, a media kit reserved for press — sharing it from Drive in the usual way is too coarse. “Anyone with the link” never expires, and adding the recipient to the file’s permissions leaks their email to anyone who looks at the share dialog. What you want is a link that works for them, for a day, and then quietly stops.

This script mints a single-use-ish URL backed by a token. The token lives in Script Properties with an expiry timestamp; when the recipient clicks the link, a web app validates the token, looks up the file, and redirects to Drive’s own download URL. After the expiry, the same link returns “expired” instead. No new Drive permissions, no Cloud project, no third-party service.

What you’ll need

  • A Google Drive file (or files) you want to share with an expiry.
  • An Apps Script project published as a web app — see Deploy Apps Script as a public web app.
  • Access set to Anyone so unauthenticated recipients can follow the link; the script does the real authorisation by checking the token.

The script

// How long a freshly minted link stays valid, in hours.
const DEFAULT_HOURS = 24;

// Property prefix so download tokens are easy to find and clean up
// without colliding with anything else in Script Properties.
const TOKEN_PREFIX = 'dl:';

/**
 * Mints a time-limited download link for a Drive file. Call this from
 * the editor, or from another script that emails the link out.
 *
 * @param {string} fileId The Drive file ID to share.
 * @param {number} [validForHours] How long the link works for. Defaults
 *   to DEFAULT_HOURS.
 * @return {string} A URL anyone can click for the next validForHours.
 */
function mintLink(fileId, validForHours = DEFAULT_HOURS) {
  if (!fileId) throw new Error('mintLink: fileId is required.');

  // Confirm the file exists before issuing a token. Catching it now
  // beats giving the recipient a token that 404s on click.
  DriveApp.getFileById(fileId);

  // A UUID is long enough that nobody guesses a working token. The
  // expiry is stored with it so doGet can check both in one read.
  const token = Utilities.getUuid();
  const expiresAt = Date.now() + validForHours * 3600 * 1000;
  PropertiesService.getScriptProperties().setProperty(
    TOKEN_PREFIX + token,
    JSON.stringify({ fileId, expiresAt })
  );

  return ScriptApp.getService().getUrl() + '?t=' + token;
}

/**
 * The public endpoint. Apps Script calls doGet with the query
 * parameters; we look up the token, confirm it has not expired, and
 * redirect to Drive's download URL.
 *
 * @param {GoogleAppsScript.Events.DoGet} e Event with parameter.t set.
 * @return {GoogleAppsScript.HTML.HtmlOutput} The page to serve.
 */
function doGet(e) {
  const token = e && e.parameter && e.parameter.t;
  if (!token) return errorPage('Missing token.');

  const raw = PropertiesService.getScriptProperties()
    .getProperty(TOKEN_PREFIX + token);
  if (!raw) return errorPage('Link not recognised.');

  const meta = JSON.parse(raw);
  if (!meta.fileId || meta.expiresAt < Date.now()) {
    return errorPage('This link has expired.');
  }

  // Drive's own download URL serves the file with the recipient's
  // session — they do not need access to the file itself, because the
  // URL carries a short-lived Drive token of its own.
  const downloadUrl = DriveApp.getFileById(meta.fileId).getDownloadUrl();
  return HtmlService.createHtmlOutput(
    '<meta http-equiv="refresh" content="0;url=' + downloadUrl + '">' +
    '<p>Starting your download&hellip;</p>'
  ).setTitle('Northwind download');
}

/**
 * Renders a friendly error page so recipients see something sensible
 * instead of a raw Apps Script error.
 *
 * @param {string} message The user-facing message.
 * @return {GoogleAppsScript.HTML.HtmlOutput}
 */
function errorPage(message) {
  return HtmlService.createHtmlOutput(
    '<h1>Northwind</h1><p>' + message + '</p>'
  ).setTitle('Northwind download');
}

/**
 * Sweeps expired tokens out of Script Properties. Run on a daily
 * trigger so the store does not grow without bound.
 */
function cleanupExpiredTokens() {
  const props = PropertiesService.getScriptProperties();
  const all = props.getProperties();
  const now = Date.now();
  let removed = 0;
  Object.keys(all).forEach((key) => {
    if (!key.startsWith(TOKEN_PREFIX)) return;
    try {
      const meta = JSON.parse(all[key]);
      if (meta.expiresAt < now) {
        props.deleteProperty(key);
        removed++;
      }
    } catch (_err) {
      props.deleteProperty(key); // junk — delete it
      removed++;
    }
  });
  Logger.log('Removed ' + removed + ' expired token(s).');
}

How it works

  1. mintLink confirms the file exists, generates a UUID token, and stores the file ID and expiry under dl:<token> in Script Properties. The returned URL is the web app’s /exec URL with ?t=<token> appended.
  2. doGet reads the t parameter, looks up the stored metadata, and rejects the request if the token is unknown or past its expiry — each branch produces a polite error page rather than a stack trace.
  3. On success, the script asks Drive for the file’s getDownloadUrl() and returns a tiny HTML page with a meta refresh that redirects the browser to that download URL. The recipient never sees the file in Drive’s UI.
  4. cleanupExpiredTokens walks the property store and deletes anything past its expiry. Run it on a daily trigger so a long-running script does not bloat Script Properties (which is capped at 500 KB).

Example run

You want to send a contract to a client. From the editor, run:

mintLink('1abcContractFileId', 24)

The function returns a URL like:

FieldValue
URLhttps://script.google.com/.../AKfyc.../exec?t=6f1c-...-9b2e
Stored propertydl:6f1c-...-9b2e
Stored value{"fileId":"1abcContractFileId","expiresAt":1761830400000}

Email the URL. The client clicks it within 24 hours, gets redirected to Drive and downloads the file. Click it the next day and the page reads “This link has expired.” — no file, no exposure.

Deploy it

  1. Paste the script into a new Apps Script project.
  2. DeployNew deployment → Web app. Execute as Me, access Anyone.
  3. Approve the authorisation prompt and copy the /exec URL.
  4. From the editor, run mintLink('YOUR_FILE_ID', 24) to test the round trip in an incognito window.
  5. Add a daily trigger for cleanupExpiredTokens: TriggersAdd trigger → Time-driven → Day timer.

Watch out for

  • “Anyone” access on the web app is required because recipients are not signed into your domain. The token is what protects the file — keep it secret in transit, send links over email rather than posting them in public chats.
  • Drive’s getDownloadUrl() carries its own short-lived Drive token in the query string. That means the redirect URL itself is not a permanent share — it works for that session and that browser, which is exactly what you want.
  • Script Properties is capped at 500 KB total and 50 properties written per second. For high-volume issuance, store tokens in a Sheet instead and key by the token in column A.
  • The token is a bearer credential — anyone with the URL gets the file. If the link is forwarded onward inside the validity window, the new holder works too. For one-shot delivery, add a usedAt field and reject any token whose property already has one set.
  • The script reads the API key pattern from Store API keys and secrets securely if you later add a signing step — for now, the UUID’s entropy carries the trust.

Related