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…</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
mintLinkconfirms the file exists, generates a UUID token, and stores the file ID and expiry underdl:<token>in Script Properties. The returned URL is the web app’s/execURL with?t=<token>appended.doGetreads thetparameter, 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.- 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. cleanupExpiredTokenswalks 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:
| Field | Value |
|---|---|
| URL | https://script.google.com/.../AKfyc.../exec?t=6f1c-...-9b2e |
| Stored property | dl: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
- Paste the script into a new Apps Script project.
- Deploy → New deployment → Web app. Execute as Me, access Anyone.
- Approve the authorisation prompt and copy the
/execURL. - From the editor, run
mintLink('YOUR_FILE_ID', 24)to test the round trip in an incognito window. - Add a daily trigger for
cleanupExpiredTokens: Triggers → Add 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
usedAtfield 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
Build a branded approval interface
Approve Northwind requests through a custom UI — clients click, decision is logged.
Updated Nov 8, 2025
Build an interactive quiz or assessment app
Run Northwind tests with scoring and feedback — questions in a Sheet, results in another.
Updated Nov 4, 2025
Build a multi-page web app with routing
Structure a real Northwind app across views — query-param routing, shared layout.
Updated Oct 31, 2025
Build a form-to-PDF web service
Convert Northwind form submissions to PDFs on the fly — POST in, PDF out.
Updated Oct 27, 2025
Build a guided onboarding tour for Sheets
Walk Northwind's first-time users through dialogs — each step explains one feature.
Updated Oct 19, 2025