Guide

Screenshot API Security Best Practices: Authentication, Rate Limiting, and Data Protection

Secure your screenshot automation with proper API key handling, rate limiting, and data protection strategies.

Asad AliNovember 22, 20258 min read

Screenshot APIs can access any public URL and potentially capture sensitive information. Proper security practices protect your API keys, prevent abuse, and ensure data privacy.

This guide covers security best practices for screenshot API integration at every level.

API Key Management

Never Hardcode Keys

The #1 security mistake:

// ❌ NEVER do this
const API_KEY = 'sk_live_abc123xyz';

// ✅ Use environment variables
const API_KEY = process.env.SCREENSHOTLY_API_KEY;

Environment Variable Security

# Development: .env file (never commit!)
SCREENSHOTLY_API_KEY=sk_test_xxx

# Add to .gitignore
echo ".env" >> .gitignore
echo ".env.local" >> .gitignore
echo ".env*.local" >> .gitignore

Production Key Management

Use secrets managers in production:

AWS Secrets Manager:

import { SecretsManager } from '@aws-sdk/client-secrets-manager';

async function getApiKey() {
  const client = new SecretsManager();
  const response = await client.getSecretValue({ SecretId: 'screenshotly-api-key' });
  return JSON.parse(response.SecretString).apiKey;
}

HashiCorp Vault:

import Vault from 'node-vault';

async function getApiKey() {
  const vault = Vault({ endpoint: process.env.VAULT_ADDR });
  const result = await vault.read('secret/data/screenshotly');
  return result.data.data.apiKey;
}

Kubernetes Secrets:

apiVersion: v1
kind: Secret
metadata:
  name: screenshotly-secrets
type: Opaque
data:
  api-key: c2tfbGl2ZV94eHg=  # base64 encoded

Key Rotation

Rotate API keys regularly:

// Support multiple keys during rotation
const API_KEYS = {
  current: process.env.SCREENSHOTLY_API_KEY,
  previous: process.env.SCREENSHOTLY_API_KEY_PREVIOUS,
};

async function captureWithFallback(url) {
  try {
    return await capture(url, API_KEYS.current);
  } catch (error) {
    if (error.status === 401 && API_KEYS.previous) {
      console.warn('Current key failed, trying previous');
      return await capture(url, API_KEYS.previous);
    }
    throw error;
  }
}

Input Validation

URL Validation

Never trust user-provided URLs:

function validateUrl(url) {
  // Check URL format
  let parsed;
  try {
    parsed = new URL(url);
  } catch {
    throw new Error('Invalid URL format');
  }
  
  // Only allow HTTP/HTTPS
  if (!['http:', 'https:'].includes(parsed.protocol)) {
    throw new Error('Only HTTP/HTTPS URLs allowed');
  }
  
  // Block internal networks
  const hostname = parsed.hostname;
  if (isInternalNetwork(hostname)) {
    throw new Error('Internal URLs not allowed');
  }
  
  // Block file:// and data: URLs
  if (parsed.protocol === 'file:' || url.startsWith('data:')) {
    throw new Error('File and data URLs not allowed');
  }
  
  return parsed.href;
}

function isInternalNetwork(hostname) {
  const internalPatterns = [
    /^localhost$/i,
    /^127\./,
    /^10\./,
    /^172\.(1[6-9]|2[0-9]|3[0-1])\./,
    /^192\.168\./,
    /^169\.254\./,
    /^::1$/,
    /^fc00:/i,
    /^fe80:/i,
    /\.local$/i,
    /\.internal$/i,
  ];
  
  return internalPatterns.some(pattern => pattern.test(hostname));
}

SSRF Prevention

Server-Side Request Forgery is a major risk:

// Additional SSRF protections
async function safeCapture(url) {
  const validatedUrl = validateUrl(url);
  
  // Resolve DNS to check actual IP
  const addresses = await dns.promises.resolve4(new URL(validatedUrl).hostname);
  
  for (const ip of addresses) {
    if (isPrivateIP(ip)) {
      throw new Error('URL resolves to private IP');
    }
  }
  
  return await captureScreenshot(validatedUrl);
}

function isPrivateIP(ip) {
  const parts = ip.split('.').map(Number);
  
  // 10.x.x.x
  if (parts[0] === 10) return true;
  
  // 172.16.x.x - 172.31.x.x
  if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true;
  
  // 192.168.x.x
  if (parts[0] === 192 && parts[1] === 168) return true;
  
  // 127.x.x.x
  if (parts[0] === 127) return true;
  
  return false;
}

Domain Allowlisting

For highest security, only allow specific domains:

const ALLOWED_DOMAINS = [
  'example.com',
  'app.example.com',
  'docs.example.com',
];

function isDomainAllowed(url) {
  const hostname = new URL(url).hostname;
  return ALLOWED_DOMAINS.some(domain => 
    hostname === domain || hostname.endsWith('.' + domain)
  );
}

Rate Limiting

Client-Side Rate Limiting

Protect yourself from accidental overuse:

import Bottleneck from 'bottleneck';

const limiter = new Bottleneck({
  maxConcurrent: 5,      // Max 5 concurrent requests
  minTime: 200,          // Min 200ms between requests
  reservoir: 100,        // Max 100 requests
  reservoirRefreshAmount: 100,
  reservoirRefreshInterval: 60 * 1000, // Per minute
});

async function rateLimitedCapture(url) {
  return limiter.schedule(() => captureScreenshot(url));
}

Handle API Rate Limits

Respect 429 responses:

async function captureWithRateLimit(url) {
  const response = await fetch(API_URL, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ url }),
  });
  
  if (response.status === 429) {
    const retryAfter = response.headers.get('Retry-After') || 60;
    console.log(`Rate limited, waiting ${retryAfter}s`);
    await sleep(parseInt(retryAfter) * 1000);
    return captureWithRateLimit(url); // Retry
  }
  
  return response;
}

Budget Controls

Set spending limits:

class BudgetTracker {
  constructor(dailyLimit) {
    this.dailyLimit = dailyLimit;
    this.usage = 0;
    this.resetAt = this.getNextReset();
  }
  
  getNextReset() {
    const tomorrow = new Date();
    tomorrow.setHours(0, 0, 0, 0);
    tomorrow.setDate(tomorrow.getDate() + 1);
    return tomorrow;
  }
  
  async checkBudget() {
    if (new Date() > this.resetAt) {
      this.usage = 0;
      this.resetAt = this.getNextReset();
    }
    
    if (this.usage >= this.dailyLimit) {
      throw new Error('Daily screenshot budget exceeded');
    }
    
    this.usage++;
    return true;
  }
}

const budget = new BudgetTracker(1000); // 1000/day limit

async function budgetedCapture(url) {
  await budget.checkBudget();
  return captureScreenshot(url);
}

Data Protection

Screenshot Storage Security

Protect stored screenshots:

import crypto from 'crypto';

// Encrypt before storage
function encryptScreenshot(buffer, key) {
  const iv = crypto.randomBytes(16);
  const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
  
  const encrypted = Buffer.concat([
    cipher.update(buffer),
    cipher.final(),
  ]);
  
  const authTag = cipher.getAuthTag();
  
  return Buffer.concat([iv, authTag, encrypted]);
}

// Decrypt when needed
function decryptScreenshot(encryptedBuffer, key) {
  const iv = encryptedBuffer.slice(0, 16);
  const authTag = encryptedBuffer.slice(16, 32);
  const data = encryptedBuffer.slice(32);
  
  const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
  decipher.setAuthTag(authTag);
  
  return Buffer.concat([
    decipher.update(data),
    decipher.final(),
  ]);
}

Automatic Cleanup

Don't keep screenshots longer than needed:

// S3 lifecycle policy
{
  "Rules": [
    {
      "ID": "DeleteOldScreenshots",
      "Status": "Enabled",
      "Filter": { "Prefix": "screenshots/" },
      "Expiration": { "Days": 30 }
    }
  ]
}

// Or programmatic cleanup
async function cleanupOldScreenshots() {
  const cutoff = new Date();
  cutoff.setDate(cutoff.getDate() - 30);
  
  const oldScreenshots = await db.screenshots
    .where('createdAt')
    .lt(cutoff)
    .toArray();
  
  for (const screenshot of oldScreenshots) {
    await storage.delete(screenshot.path);
    await db.screenshots.delete(screenshot.id);
  }
}

Access Logging

Track who accesses screenshots:

async function getScreenshot(id, userId) {
  const screenshot = await db.screenshots.get(id);
  
  if (!screenshot) {
    throw new Error('Screenshot not found');
  }
  
  // Check access permissions
  if (!hasAccess(userId, screenshot)) {
    await logUnauthorizedAccess(userId, id);
    throw new Error('Access denied');
  }
  
  // Log access
  await db.accessLogs.insert({
    screenshotId: id,
    userId,
    action: 'view',
    timestamp: new Date(),
    ip: getClientIP(),
  });
  
  return screenshot;
}

Sensitive Content Handling

Content Warnings

Screenshots may capture sensitive data:

// Warn before capturing financial sites
const SENSITIVE_PATTERNS = [
  /bank/i,
  /payment/i,
  /checkout/i,
  /account/i,
  /login/i,
  /signin/i,
];

function checkSensitiveUrl(url) {
  const isSensitive = SENSITIVE_PATTERNS.some(p => p.test(url));
  
  if (isSensitive) {
    console.warn(`⚠️ Capturing potentially sensitive URL: ${url}`);
    // Consider additional protections
  }
  
  return isSensitive;
}

PII Detection

Consider scanning screenshots for sensitive data:

// Placeholder for OCR + PII detection
async function checkForPII(screenshotBuffer) {
  const text = await performOCR(screenshotBuffer);
  
  const patterns = {
    ssn: /\d{3}-\d{2}-\d{4}/,
    creditCard: /\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}/,
    email: /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/,
  };
  
  const found = [];
  for (const [type, pattern] of Object.entries(patterns)) {
    if (pattern.test(text)) {
      found.push(type);
    }
  }
  
  if (found.length > 0) {
    console.warn(`PII detected: ${found.join(', ')}`);
    // Consider redaction or alerting
  }
  
  return found;
}

Audit Trail

Comprehensive Logging

Log all screenshot activity:

async function auditedCapture(url, userId, purpose) {
  const auditId = crypto.randomUUID();
  const startTime = Date.now();
  
  try {
    // Log request
    await audit.log({
      id: auditId,
      action: 'screenshot_request',
      userId,
      url,
      purpose,
      timestamp: new Date(),
      ip: getClientIP(),
    });
    
    const result = await captureScreenshot(url);
    
    // Log success
    await audit.log({
      id: auditId,
      action: 'screenshot_success',
      duration: Date.now() - startTime,
      size: result.length,
    });
    
    return result;
    
  } catch (error) {
    // Log failure
    await audit.log({
      id: auditId,
      action: 'screenshot_failure',
      error: error.message,
      duration: Date.now() - startTime,
    });
    
    throw error;
  }
}

Security Checklist

Before Deployment

  • API keys stored in environment/secrets manager
  • .env files in .gitignore
  • URL validation implemented
  • SSRF protections in place
  • Rate limiting configured
  • Budget controls set
  • Access logging enabled
  • Screenshot encryption considered
  • Retention policies defined
  • Audit logging implemented

Ongoing

  • Rotate API keys quarterly
  • Review access logs monthly
  • Audit URL patterns allowed
  • Monitor for anomalies
  • Update dependencies
  • Test security controls

Conclusion

Screenshot API security requires attention at every layer:

  1. Key management - Never expose API keys
  2. Input validation - Validate and sanitize URLs
  3. Rate limiting - Prevent abuse and cost overruns
  4. Data protection - Encrypt and expire screenshots
  5. Audit logging - Track all screenshot activity

Implement these practices from day one—security becomes harder to add later.


Ready for secure screenshot automation?

Get your free API key → - 100 free screenshots to get started.

See also: Error Handling Guide →

security
api
best practices
authentication

About the Author

Asad Ali

Asad Ali

Full-Stack Developer and Founder of ZTabs with 8+ years of experience building scalable web applications and APIs. Specializes in performance optimization, SaaS development, and modern web technologies.

Credentials: Founder & CEO at ZTabs, Full-Stack Developer, Expert in Next.js, React, Node.js, and API optimization

Ready to capture your first screenshot?

Get started with 100 free screenshots. No credit card required.

Related Articles