This guide provides a tiered approach to storing sensitive credentials (API keys, passwords, database strings) in Google Apps Script, moving from basic individual usage to enterprise-grade security.
Depending on your use case, you should choose the storage method that matches your security and sharing requirements.
| Method | Best For | Security | Visibility |
|---|---|---|---|
| User Properties | Personal scripts or shared tools where each user has their own API key. | High | Only the current user. |
| Script Properties | Team-wide tools where one API key is shared by all editors. | Medium | All script editors. |
| GCP Secret Manager | Enterprise apps requiring auditing, rotation, and high security. | Maximum | Managed via IAM permissions. |
| Protected Sheet | Beginners or simple mockups. | Low | Sheet owner (risky if not hidden). |
The recommended approach is to use a menu-based setup function to collect and store secrets, then access them directly in your functions. This pattern works for both custom functions and regular functions.
Custom functions (functions with @customfunction tag used in spreadsheet formulas) cannot call SpreadsheetApp.getUi(). They run in a restricted context that doesn't allow UI interactions.
Solution: Use a menu-based setup function to store secrets, then access them directly in all functions:
/**
* Creates a custom menu when the spreadsheet opens
*/
function onOpen() {
const ui = SpreadsheetApp.getUi();
ui.createMenu('🔐 API Settings')
.addItem('Setup API Key', 'setupApiKey')
.addItem('Clear Stored Secrets', 'resetMySecrets')
.addToUi();
}
/**
* Setup function to prompt for and store the API key
*/
function setupApiKey() {
const ui = SpreadsheetApp.getUi();
const response = ui.prompt(
'Setup Required',
'Please enter your API Key:',
ui.ButtonSet.OK_CANCEL
);
if (response.getSelectedButton() == ui.Button.OK) {
const input = response.getResponseText().trim();
if (input) {
PropertiesService.getUserProperties().setProperty('MY_API_KEY', input);
ui.alert('✅ API Key saved successfully!');
}
}
}
/**
* Custom function that accesses the stored secret WITHOUT UI prompts
* @customfunction
*/
function MY_CUSTOM_FUNCTION(param) {
const apiKey = PropertiesService.getUserProperties().getProperty('MY_API_KEY');
if (!apiKey) {
return "⚠️ Setup Required: Go to '🔐 API Settings' menu → 'Setup API Key'";
}
// Use apiKey for your logic...
}
/**
* Regular function that also accesses the stored secret directly
*/
function runMyTool() {
const apiKey = PropertiesService.getUserProperties().getProperty('MY_API_KEY');
if (!apiKey) {
SpreadsheetApp.getUi().alert("⚠️ Setup Required: Go to '🔐 API Settings' menu → 'Setup API Key'");
return;
}
// Proceed with your logic...
console.log("Task running with authenticated key.");
}
/**
* Clear settings utility
*/
function resetMySecrets() {
PropertiesService.getUserProperties().deleteAllProperties();
SpreadsheetApp.getUi().alert('Your private credentials have been cleared.');
}
For production environments, you should avoid PropertiesService entirely and use GCP Secret Manager. This allows you to rotate keys without updating your script code.
function getGCPSecret(secretName) {
const projectId = "YOUR_GCP_PROJECT_ID";
const url = `https://secretmanager.googleapis.com/v1/projects/${projectId}/secrets/${secretName}/versions/latest:access`;
const options = {
headers: { Authorization: 'Bearer ' + ScriptApp.getOAuthToken() },
muteHttpExceptions: true
};
const response = UrlFetchApp.fetch(url, options);
if (response.getResponseCode() !== 200) throw new Error("Could not fetch secret from GCP.");
const payload = JSON.parse(response.getContentText());
return Utilities.newBlob(Utilities.base64Decode(payload.payload.data)).getDataAsString();
}
Cause: You're trying to call SpreadsheetApp.getUi() from a custom function (@customfunction). Custom functions run in a restricted execution context that doesn't allow UI interactions.
Solution:
1. Create a separate menu function (via onOpen()) to collect and store the secret
2. In your custom function, access the stored property directly using PropertiesService.getUserProperties().getProperty('KEY_NAME')
3. Return a helpful error message if the key is missing, directing users to the setup menu
Example:
// ✅ CORRECT: Menu function can show UI
function setupApiKey() {
const ui = SpreadsheetApp.getUi();
const response = ui.prompt('Enter API Key:');
if (response.getSelectedButton() == ui.Button.OK) {
PropertiesService.getUserProperties().setProperty('API_KEY', response.getResponseText());
}
}
// ✅ CORRECT: Custom function accesses property directly
function MY_FUNCTION() {
const key = PropertiesService.getUserProperties().getProperty('API_KEY');
if (!key) return "⚠️ Run 'Setup API Key' from menu first";
// Use key...
}
// ❌ WRONG: Custom function tries to show UI
function MY_FUNCTION() {
const ui = SpreadsheetApp.getUi(); // Will fail in custom functions!
const response = ui.prompt('Enter API Key:');
}
function checkStoredKeys() {
const props = PropertiesService.getUserProperties().getProperties();
console.log(props); // Shows all stored keys
}
function deleteSpecificKey() {
PropertiesService.getUserProperties().deleteProperty('MY_API_KEY');
SpreadsheetApp.getUi().alert('Key deleted.');
}
Yes, but be aware of the security implications: - UserProperties: Private to each user (more secure, prevents credit hijacking) - ScriptProperties: Shared among all editors (use only when necessary)
// Store in UserProperties (private)
PropertiesService.getUserProperties().setProperty('KEY', 'value');
// Store in ScriptProperties (shared with all editors)
PropertiesService.getScriptProperties().setProperty('KEY', 'value');
Yes. The first time you run a function that uses PropertiesService or UrlFetchApp, Google will prompt you to authorize the script. This is normal and required.
This project is licensed under the MIT License.
InnovUmabot | https://innovumabot.com
This tool was vibe-coded with AI with strict human supervision.