Progressive Web Apps (PWA)

PWA Security

20 min Lesson 18 of 30

PWA Security

Security is paramount in Progressive Web Apps. This lesson covers essential security practices including HTTPS enforcement, Content Security Policy, service worker security, and protecting against common web vulnerabilities.

HTTPS Enforcement

HTTPS is mandatory for PWAs. Service workers, push notifications, and many modern APIs only work over secure connections.

<!-- Redirect HTTP to HTTPS --> <script> // Force HTTPS redirect if (window.location.protocol !== 'https:' && window.location.hostname !== 'localhost') { window.location.href = 'https:' + window.location.href.substring(window.location.protocol.length); } // Check for secure context if (window.isSecureContext) { console.log('Running in secure context (HTTPS)'); // Initialize PWA features initServiceWorker(); } else { console.warn('Not in secure context. PWA features unavailable.'); } </script>
// Server-side HTTPS enforcement (Node.js/Express) const express = require('express'); const app = express(); // Force HTTPS middleware app.use((req, res, next) => { if (req.headers['x-forwarded-proto'] !== 'https' && process.env.NODE_ENV === 'production') { return res.redirect(301, `https://${req.headers.host}${req.url}`); } next(); }); // HSTS (HTTP Strict Transport Security) app.use((req, res, next) => { res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload'); next(); });
Note: HSTS (HTTP Strict Transport Security) tells browsers to always use HTTPS for your domain. The preload directive allows submission to the HSTS preload list.

Content Security Policy (CSP)

CSP prevents XSS attacks by controlling which resources can be loaded and executed on your page.

<!-- CSP via meta tag --> <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' https://trusted-cdn.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https://api.example.com; font-src 'self' https://fonts.googleapis.com; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests;">
// Server-side CSP headers (Node.js/Express) const helmet = require('helmet'); app.use(helmet.contentSecurityPolicy({ directives: { defaultSrc: ["'self'"], scriptSrc: ["'self'", "https://trusted-cdn.com"], styleSrc: ["'self'", "'unsafe-inline'"], // Avoid 'unsafe-inline' if possible imgSrc: ["'self'", "data:", "https:"], connectSrc: ["'self'", "https://api.example.com"], fontSrc: ["'self'", "https://fonts.googleapis.com"], objectSrc: ["'none'"], mediaSrc: ["'self'"], frameSrc: ["'none'"], baseUri: ["'self'"], formAction: ["'self'"], frameAncestors: ["'none'"], upgradeInsecureRequests: [] } })); // CSP violation reporting app.use(helmet.contentSecurityPolicy({ directives: { // ... other directives reportUri: '/csp-violation-report' } })); app.post('/csp-violation-report', express.json({ type: 'application/csp-report' }), (req, res) => { console.log('CSP Violation:', req.body); // Log to security monitoring system res.status(204).end(); });
Warning: Avoid using 'unsafe-inline' and 'unsafe-eval' in CSP. They significantly weaken security. Use nonces or hashes for inline scripts instead.

Service Worker Security

Service workers have powerful capabilities and must be secured properly.

// service-worker.js - Secure caching practices const CACHE_NAME = 'pwa-v1'; const ALLOWED_ORIGINS = [ 'https://your-domain.com', 'https://api.your-domain.com' ]; // Only cache same-origin or allowed cross-origin requests self.addEventListener('fetch', (event) => { const url = new URL(event.request.url); // Security checks if (!ALLOWED_ORIGINS.includes(url.origin) && url.origin !== self.location.origin) { // Don't cache third-party resources return; } // Don't cache sensitive requests if (url.pathname.includes('/api/auth') || url.pathname.includes('/api/payment')) { return; } // Validate request method if (event.request.method !== 'GET') { return; } event.respondWith( caches.match(event.request).then((cachedResponse) => { if (cachedResponse) { return cachedResponse; } return fetch(event.request).then((response) => { // Don't cache error responses if (!response || response.status !== 200) { return response; } // Clone response for cache const responseToCache = response.clone(); caches.open(CACHE_NAME).then((cache) => { cache.put(event.request, responseToCache); }); return response; }); }) ); }); // Secure message handling self.addEventListener('message', (event) => { // Verify message origin if (event.origin !== 'https://your-domain.com') { console.warn('Message from untrusted origin:', event.origin); return; } // Validate message data if (!event.data || typeof event.data.action !== 'string') { console.warn('Invalid message format'); return; } // Handle only whitelisted actions const allowedActions = ['skipWaiting', 'clearCache']; if (!allowedActions.includes(event.data.action)) { console.warn('Unauthorized action:', event.data.action); return; } // Process action if (event.data.action === 'skipWaiting') { self.skipWaiting(); } });

Secure Caching Strategies

Implement caching with security in mind to protect sensitive data.

// Secure cache management const SENSITIVE_PATHS = [ '/api/user', '/api/auth', '/api/payment', '/profile' ]; // Check if URL contains sensitive data function isSensitiveRequest(url) { return SENSITIVE_PATHS.some(path => url.pathname.includes(path)); } // Cache with security checks async function secureCacheResponse(request, response) { const url = new URL(request.url); // Don't cache sensitive data if (isSensitiveRequest(url)) { return response; } // Don't cache if response has no-store directive const cacheControl = response.headers.get('cache-control'); if (cacheControl && cacheControl.includes('no-store')) { return response; } // Don't cache authenticated requests if (request.headers.has('authorization')) { return response; } // Safe to cache const cache = await caches.open(CACHE_NAME); cache.put(request, response.clone()); return response; } // Clear sensitive data on logout self.addEventListener('message', async (event) => { if (event.data.action === 'logout') { const cache = await caches.open(CACHE_NAME); const requests = await cache.keys(); // Remove sensitive cached data for (const request of requests) { const url = new URL(request.url); if (isSensitiveRequest(url)) { await cache.delete(request); } } } });

Token Storage and Management

Properly store authentication tokens to prevent token theft and XSS attacks.

<script> // Secure token storage best practices class SecureStorage { // Store access token in memory (most secure, lost on refresh) static accessToken = null; // Store refresh token in httpOnly cookie (server-side only) static setAccessToken(token) { this.accessToken = token; // DO NOT store in localStorage } static getAccessToken() { return this.accessToken; } static clearAccessToken() { this.accessToken = null; } // Use httpOnly cookies for refresh tokens (set by server) static async refreshAccessToken() { const response = await fetch('/api/refresh-token', { method: 'POST', credentials: 'include' // Send httpOnly cookie }); if (response.ok) { const data = await response.json(); this.setAccessToken(data.accessToken); return data.accessToken; } throw new Error('Token refresh failed'); } } // Secure API calls with token async function secureApiCall(url, options = {}) { let token = SecureStorage.getAccessToken(); // Try to refresh if token is missing if (!token) { token = await SecureStorage.refreshAccessToken(); } const response = await fetch(url, { ...options, headers: { ...options.headers, 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' } }); // Handle token expiration if (response.status === 401) { token = await SecureStorage.refreshAccessToken(); return secureApiCall(url, options); // Retry with new token } return response; } // Clear tokens on logout function logout() { SecureStorage.clearAccessToken(); // Notify service worker if (navigator.serviceWorker.controller) { navigator.serviceWorker.controller.postMessage({ action: 'logout' }); } // Clear sensitive data sessionStorage.clear(); } </script>
Tip: Never store sensitive tokens in localStorage or sessionStorage. They're vulnerable to XSS attacks. Use httpOnly cookies for refresh tokens and in-memory storage for access tokens.

XSS Prevention

Protect against Cross-Site Scripting attacks with proper input handling and output encoding.

<script> // Sanitize user input to prevent XSS function sanitizeHTML(input) { const div = document.createElement('div'); div.textContent = input; return div.innerHTML; } // Use DOMPurify for rich HTML content function sanitizeRichHTML(html) { // Include DOMPurify library: https://github.com/cure53/DOMPurify return DOMPurify.sanitize(html, { ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'], ALLOWED_ATTR: ['href', 'target'] }); } // Safely render user content function displayUserContent(content) { const container = document.getElementById('userContent'); // Bad: Vulnerable to XSS // container.innerHTML = content; // Good: Safe from XSS container.textContent = content; // Or use sanitized HTML if formatting is needed container.innerHTML = sanitizeRichHTML(content); } // Validate and sanitize form inputs function validateInput(input, type) { switch (type) { case 'email': return /^[\w.-]+@[\w.-]+\.\w+$/.test(input); case 'url': try { new URL(input); return input.startsWith('https://'); } catch { return false; } case 'alphanumeric': return /^[a-zA-Z0-9]+$/.test(input); default: return false; } } // Example form handling document.getElementById('userForm').addEventListener('submit', (e) => { e.preventDefault(); const name = document.getElementById('name').value; const email = document.getElementById('email').value; // Validate inputs if (!validateInput(email, 'email')) { alert('Invalid email address'); return; } // Sanitize before sending const data = { name: sanitizeHTML(name), email: sanitizeHTML(email) }; // Send to server fetch('/api/submit', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }); }); </script>

CORS Configuration

Configure Cross-Origin Resource Sharing properly to control API access.

// Server-side CORS configuration (Node.js/Express) const cors = require('cors'); // Restrictive CORS (recommended) const corsOptions = { origin: function (origin, callback) { const allowedOrigins = [ 'https://your-domain.com', 'https://app.your-domain.com' ]; if (!origin || allowedOrigins.includes(origin)) { callback(null, true); } else { callback(new Error('Not allowed by CORS')); } }, credentials: true, // Allow cookies methods: ['GET', 'POST', 'PUT', 'DELETE'], allowedHeaders: ['Content-Type', 'Authorization'], maxAge: 86400 // Cache preflight for 24 hours }; app.use(cors(corsOptions)); // Different CORS policies for different routes app.use('/api/public', cors()); // Public API app.use('/api/private', cors(corsOptions)); // Restricted API
Exercise:
  1. Implement HTTPS enforcement and HSTS headers in your PWA
  2. Configure a strict Content Security Policy and test for violations
  3. Secure your service worker with origin checks and request validation
  4. Implement secure token storage using httpOnly cookies and in-memory storage
  5. Add XSS prevention measures including input sanitization and output encoding

Security Checklist

  • HTTPS Everywhere: Enforce HTTPS with HSTS and upgrade-insecure-requests
  • Content Security Policy: Implement strict CSP without 'unsafe-inline' or 'unsafe-eval'
  • Service Worker Scope: Limit service worker scope to minimize attack surface
  • Token Security: Use httpOnly cookies for refresh tokens, in-memory for access tokens
  • Input Validation: Validate and sanitize all user inputs
  • Output Encoding: Encode all user content before rendering
  • CORS Configuration: Whitelist specific origins, avoid using wildcard (*)
  • Secure Caching: Never cache sensitive data (auth tokens, payment info, personal data)
  • Regular Updates: Keep dependencies updated to patch security vulnerabilities
  • Security Headers: Implement X-Frame-Options, X-Content-Type-Options, Referrer-Policy