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:
- Implement HTTPS enforcement and HSTS headers in your PWA
- Configure a strict Content Security Policy and test for violations
- Secure your service worker with origin checks and request validation
- Implement secure token storage using httpOnly cookies and in-memory storage
- 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