Encryption, OAuth, and XSS Prevention in Zero-Infrastructure Applications
The Challenge: Building secure applications without traditional backend infrastructure requires rethinking security architecture. When you move to a serverless model, you can't rely on server-side validation, session management, or traditional authentication flows.
The Solution: A comprehensive security architecture that leverages modern browser APIs, OAuth 2.0, and defense-in-depth principles. This article covers the complete security stack I've implemented, from encryption to XSS prevention.
All sensitive data is encrypted before storage using AES-256-GCM (Galois/Counter Mode). This provides both confidentiality and integrity through authenticated encryption.
// Encryption Utility
const CryptoUtils = {
// Generate a random encryption key
async generateKey() {
return await crypto.subtle.generateKey(
{
name: 'AES-GCM',
length: 256
},
true,
['encrypt', 'decrypt']
);
},
// Encrypt data
async encrypt(data, key) {
const encoder = new TextEncoder();
const iv = crypto.getRandomValues(new Uint8Array(12));
const encrypted = await crypto.subtle.encrypt(
{
name: 'AES-GCM',
iv: iv
},
key,
encoder.encode(data)
);
return {
ciphertext: this.arrayBufferToBase64(encrypted),
iv: this.arrayBufferToBase64(iv)
};
},
// Decrypt data
async decrypt(encryptedData, key) {
const ciphertext = this.base64ToArrayBuffer(encryptedData.ciphertext);
const iv = this.base64ToArrayBuffer(encryptedData.iv);
const decrypted = await crypto.subtle.decrypt(
{
name: 'AES-GCM',
iv: iv
},
key,
ciphertext
);
const decoder = new TextDecoder();
return decoder.decode(decrypted);
},
arrayBufferToBase64(buffer) {
return btoa(String.fromCharCode(...new Uint8Array(buffer)));
},
base64ToArrayBuffer(base64) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
}
};
For secure key exchange, I use RSA-OAEP with 4096-bit keys. This allows secure communication between parties without pre-shared secrets.
// RSA Key Generation
const RSAUtils = {
async generateKeyPair() {
return await crypto.subtle.generateKey(
{
name: 'RSA-OAEP',
modulusLength: 4096,
publicExponent: new Uint8Array([1, 0, 1]),
hash: 'SHA-256'
},
true,
['encrypt', 'decrypt']
);
},
async exportPublicKey(key) {
const exported = await crypto.subtle.exportKey('spki', key);
return this.arrayBufferToBase64(exported);
},
async importPublicKey(pem) {
const binary = this.base64ToArrayBuffer(pem);
return await crypto.subtle.importKey(
'spki',
binary,
{ name: 'RSA-OAEP', hash: 'SHA-256' },
true,
['encrypt']
);
}
};
The OAuth 2.0 PKCE (Proof Key for Code Exchange) flow prevents authorization code interception attacks. This is critical for public clients (like single-page apps).
// OAuth 2.0 with PKCE
const OAuth = {
config: {
clientId: 'YOUR_CLIENT_ID',
redirectUri: window.location.origin + '/auth/callback',
scope: 'repo user',
authEndpoint: 'https://github.com/login/oauth/authorize',
tokenEndpoint: 'https://github.com/login/oauth/access_token'
},
// Generate code verifier for PKCE
async generateCodeVerifier() {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return this.base64UrlEncode(array);
},
// Generate code challenge from verifier
async generateCodeChallenge(verifier) {
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const digest = await crypto.subtle.digest('SHA-256', data);
return this.base64UrlEncode(new Uint8Array(digest));
},
// Initiate OAuth flow
async login() {
const verifier = await this.generateCodeVerifier();
const challenge = await this.generateCodeChallenge(verifier);
// Store verifier for callback
sessionStorage.setItem('pkce_verifier', verifier);
const params = new URLSearchParams({
client_id: this.config.clientId,
redirect_uri: this.config.redirectUri,
scope: this.config.scope,
response_type: 'code',
code_challenge: challenge,
code_challenge_method: 'S256'
});
window.location.href = `${this.config.authEndpoint}?${params}`;
},
// Exchange code for token
async exchangeCodeForToken(code) {
const verifier = sessionStorage.getItem('pkce_verifier');
const response = await fetch(this.config.tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({
client_id: this.config.clientId,
code: code,
redirect_uri: this.config.redirectUri,
code_verifier: verifier
})
});
if (!response.ok) {
throw new Error('Token exchange failed');
}
const data = await response.json();
// Store token securely
await this.storeToken(data.access_token);
return data.access_token;
},
async storeToken(token) {
// Use encrypted sessionStorage
const key = await this.getOrCreateEncryptionKey();
const encrypted = await CryptoUtils.encrypt(token, key);
sessionStorage.setItem('encrypted_token', JSON.stringify(encrypted));
},
async getToken() {
const encrypted = JSON.parse(sessionStorage.getItem('encrypted_token'));
if (!encrypted) return null;
const key = await this.getOrCreateEncryptionKey();
return await CryptoUtils.decrypt(encrypted, key);
},
base64UrlEncode(array) {
return btoa(String.fromCharCode(...array))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
};
Access tokens are stored encrypted and automatically refreshed before expiration:
// Token Manager
const TokenManager = {
async refreshToken() {
const token = await OAuth.getToken();
if (!token) return null;
// Check expiration
const payload = this.parseJwt(token);
const expiresAt = payload.exp * 1000;
const now = Date.now();
// Refresh if expiring within 5 minutes
if (expiresAt - now < 300000) {
const newToken = await this.fetchNewToken();
await OAuth.storeToken(newToken);
return newToken;
}
return token;
},
parseJwt(token) {
const base64Url = token.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const jsonPayload = decodeURIComponent(atob(base64).split('').map(c => {
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
}).join(''));
return JSON.parse(jsonPayload);
}
};
A strict CSP header prevents XSS attacks by controlling which resources can be loaded:
// Content Security Policy
const CSP_POLICY = `
default-src 'none';
script-src 'self' https://cdn.jsdelivr.net;
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
font-src 'self' https://fonts.gstatic.com;
img-src 'self' data: https: http:;
connect-src 'self' https://api.github.com;
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
frame-src 'none';
object-src 'none';
require-trusted-types-for 'script';
`.trim().replace(/\s+/g, ' ');
// Apply CSP via meta tag (for GitHub Pages)
const meta = document.createElement('meta');
meta.httpEquiv = 'Content-Security-Policy';
meta.content = CSP_POLICY;
document.head.appendChild(meta);
All user-generated HTML is sanitized using DOMPurify before rendering:
// HTML Sanitization
const Sanitizer = {
// DOMPurify configuration
config: {
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 'a', 'h1', 'h2', 'h3', 'ul', 'ol', 'li', 'code', 'pre', 'blockquote'],
ALLOWED_ATTR: ['href', 'title', 'class'],
ALLOW_DATA_ATTR: false,
SAFE_FOR_TEMPLATES: true,
SANITIZE_DOM: true,
KEEP_CONTENT: true
},
sanitize(html) {
return DOMPurify.sanitize(html, this.config);
},
// Safe HTML insertion
safeInsert(element, html) {
const clean = this.sanitize(html);
element.innerHTML = clean;
},
// Safe text content
safeText(element, text) {
element.textContent = text;
}
};
Modern browsers support Trusted Types, which prevent XSS from DOM sinks:
// Trusted Types Policy
if (window.trustedTypes && window.trustedTypes.createPolicy) {
const escapePolicy = window.trustedTypes.createPolicy('escapePolicy', {
createHTML: (string) => {
return DOMPurify.sanitize(string, { SANITIZE_DOM: true });
},
createScriptURL: (string) => {
const url = new URL(string, window.location.origin);
if (url.origin !== window.location.origin) {
throw new Error('External scripts not allowed');
}
return string.toString();
}
});
}
// Force HTTPS
if (location.protocol !== 'https:' && location.hostname !== 'localhost') {
location.replace(`https:${location.href.substring(location.protocol.length)}`);
}
Proper CORS headers prevent cross-origin attacks:
// CORS Configuration
const corsHeaders = {
'Access-Control-Allow-Origin': window.location.origin,
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Max-Age': '86400',
'Access-Control-Allow-Credentials': 'true'
};
After implementing this security architecture:
Never store access tokens in localStorage. Any JavaScript can access it, including malicious scripts from XSS vulnerabilities.
// BAD - Token accessible to any script
localStorage.setItem('token', accessToken);
// GOOD - Token encrypted, limited access
await OAuth.storeToken(accessToken);
For public clients (SPAs), PKCE is mandatory. Without it, your OAuth flow is vulnerable to authorization code interception.
Avoid unsafe-inline and unsafe-eval in your CSP. These weaken your security posture significantly.
Always validate input on the client side, even if you don't have a backend. This prevents many common attacks.
Building secure applications without traditional infrastructure is challenging but entirely possible. By leveraging modern browser APIs like Web Crypto, implementing OAuth 2.0 with PKCE, and following defense-in-depth principles, you can create applications that are more secure than many traditional web applications.
The key is to never trust the client—even when you are the client. Validate everything, encrypt everything, and assume that any input could be malicious.
Security is not a feature you add at the end—it's a foundation you build upon.