This document evaluates free backend options for the Global Anti-CCP Resistance Hub, explains the chosen security model, and provides setup instructions.
Context: This site serves activists, dissidents, and human rights defenders who may face real-world danger if their personal information is exposed. Security is not optional — it is a fundamental requirement.
Who are we protecting?
- People submitting incident reports about CCP harassment
- Volunteers providing their names and contact information
- Newsletter subscribers whose email addresses could identify them
- Anyone whose participation in anti-CCP activities could endanger them or their families
What are the threats?
- Database breach (external hacking or insider access)
- Legal compulsion (government demands for data)
- Network interception (man-in-the-middle attacks)
- Metadata analysis (identifying users by access patterns)
| Feature | Details |
|---|---|
| Cost | Free — 500MB database, 1GB file storage, 50K monthly active users |
| Encryption in transit | TLS 1.2+ (HTTPS) for all connections |
| Encryption at rest | AES-256 on AWS infrastructure |
| Access control | Row Level Security (RLS) — users can INSERT but not READ |
| Compliance | SOC 2 Type II certified |
| Infrastructure | AWS, multiple regions (US, EU, Asia-Pacific) |
| Already integrated | Yes — all 4 forms are wired, RLS policies defined |
Why Supabase wins:
- Already fully integrated in the codebase (zero migration work)
- Free tier is generous enough for this project
- RLS prevents any user from reading other submissions
- Combined with client-side encryption (see below), provides defense-in-depth
| Feature | Details |
|---|---|
| Cost | Free — 5GB storage, 5M reads/day, 100K writes/day |
| Pros | Already on Cloudflare for hosting; no new vendor |
| Cons | Requires writing Cloudflare Workers (serverless functions) and SQL migrations |
| Why not | Supabase is already integrated; switching would require rewriting all form services |
| Feature | Details |
|---|---|
| Cost | Free tiers available |
| Cons | Data is readable by the service provider; no client-side encryption support |
| Why not | Unacceptable for sensitive activist data — no way to prevent provider access |
| Feature | Details |
|---|---|
| Cost | Free if you have a server; Docker Compose already exists in backend/ |
| Cons | Requires server maintenance, security patching, backups |
| Why not | Operational burden too high for a volunteer-maintained project |
| Feature | Details |
|---|---|
| Approach | Replace all forms with links to established organizations |
| Why not | Forms serve a real purpose — incident reports, volunteer coordination |
| Fallback | The site already degrades gracefully when Supabase is not configured |
┌──────────────────────────────────────────────────────────────────────┐
│ USER'S BROWSER │
│ │
│ 1. User fills out form │
│ 2. encryption.js encrypts PII fields with admin's RSA public key │
│ (name, email, message → AES-256-GCM → RSA-OAEP wrapped key) │
│ 3. Encrypted data sent via HTTPS to Supabase │
└───────────────────────────┬──────────────────────────────────────────┘
│ HTTPS (TLS 1.2+)
▼
┌──────────────────────────────────────────────────────────────────────┐
│ SUPABASE (PostgreSQL + RLS) │
│ │
│ • Data stored as encrypted ciphertext │
│ • RLS: anon users can INSERT only, cannot SELECT/UPDATE/DELETE │
│ • Supabase encrypts entire database at rest (AES-256) │
│ • Even Supabase employees see only ciphertext for PII fields │
└───────────────────────────┬──────────────────────────────────────────┘
│ Admin accesses via Dashboard
▼
┌──────────────────────────────────────────────────────────────────────┐
│ ADMIN (you) │
│ │
│ • Views submissions in Supabase Dashboard │
│ • Decrypts PII fields offline using private key │
│ • Private key NEVER leaves admin's machine │
└──────────────────────────────────────────────────────────────────────┘
- TLS/HTTPS — Data encrypted in transit (browser → Supabase)
- Client-side encryption — PII encrypted before it leaves the browser
- Row Level Security — Database prevents users from reading others' data
- Encryption at rest — Supabase/AWS encrypts the entire database on disk
- Key separation — Only the admin holds the decryption key
- If Supabase is breached: Attackers see encrypted ciphertext, not names/emails
- If network is intercepted: TLS prevents reading, and PII is double-encrypted
- If a government demands data: Supabase can only hand over ciphertext
- If admin's machine is compromised: Only that one private key is at risk
Follow the existing Supabase Setup Guide. It covers:
- Creating a Supabase project (free)
- Running the SQL to create tables and RLS policies
- Setting
VITE_SUPABASE_URLandVITE_SUPABASE_ANON_KEY
This creates an RSA key pair for client-side encryption. Run this in Node.js (or any JavaScript runtime with Web Crypto API support):
// generate-keys.mjs — Run with: node generate-keys.mjs
import { webcrypto } from 'node:crypto';
const { subtle } = webcrypto;
async function generateKeys() {
const keyPair = await subtle.generateKey(
{
name: 'RSA-OAEP',
modulusLength: 4096,
publicExponent: new Uint8Array([1, 0, 1]),
hash: 'SHA-256',
},
true,
['wrapKey', 'unwrapKey']
);
const publicJWK = await subtle.exportKey('jwk', keyPair.publicKey);
const privateJWK = await subtle.exportKey('jwk', keyPair.privateKey);
const publicB64 = Buffer.from(JSON.stringify(publicJWK)).toString('base64');
console.log('=== PUBLIC KEY (set as VITE_ENCRYPTION_PUBLIC_KEY) ===');
console.log(publicB64);
console.log('');
console.log('=== PRIVATE KEY (save securely, NEVER commit or share) ===');
console.log(JSON.stringify(privateJWK, null, 2));
console.log('');
console.log('IMPORTANT:');
console.log('1. Set VITE_ENCRYPTION_PUBLIC_KEY in your .env or hosting dashboard');
console.log('2. Save the private key in a secure location (password manager, encrypted file)');
console.log('3. NEVER commit the private key to version control');
}
generateKeys();Add the base64-encoded public key to your environment:
Local development (.env):
VITE_ENCRYPTION_PUBLIC_KEY=eyJhbGciOiJSU0EtT0FFUCIs...
Cloudflare Pages: Add as an environment variable in your Cloudflare dashboard.
When you need to read encrypted submissions, use this script with your private key:
// decrypt-submission.mjs — Run with: node decrypt-submission.mjs
import { webcrypto } from 'node:crypto';
import { readFileSync } from 'node:fs';
const { subtle } = webcrypto;
// Load your private key (keep this file secure!)
const privateJWK = JSON.parse(readFileSync('private-key.json', 'utf-8'));
function base64ToArrayBuffer(b64) {
const binary = Buffer.from(b64, 'base64');
return binary.buffer.slice(binary.byteOffset, binary.byteOffset + binary.byteLength);
}
async function decrypt(encryptedField, wrappedKeyB64) {
const privateKey = await subtle.importKey(
'jwk', privateJWK,
{ name: 'RSA-OAEP', hash: 'SHA-256' },
false, ['unwrapKey']
);
const aesKey = await subtle.unwrapKey(
'raw',
base64ToArrayBuffer(wrappedKeyB64),
privateKey,
{ name: 'RSA-OAEP' },
{ name: 'AES-GCM', length: 256 },
false, ['decrypt']
);
const decrypted = await subtle.decrypt(
{ name: 'AES-GCM', iv: base64ToArrayBuffer(encryptedField.iv) },
aesKey,
base64ToArrayBuffer(encryptedField.ciphertext)
);
return new TextDecoder().decode(decrypted);
}
// Example: decrypt a field from a Supabase row
// const row = { ... }; // fetched from Supabase
// const name = await decrypt(row.name, row._encryption.wrappedKey);
// console.log('Decrypted name:', name);The following guidance is shown on all forms:
- Use a secure email provider — ProtonMail, Tutanota, or similar
- Use Tor or a VPN — Prevents network-level surveillance
- Use a pseudonym — Where real name is not required
- Submit anonymously — Incident and sighting reports default to anonymous
- Do not upload files — No file upload is supported (by design)
Forms collect only what is necessary:
| Form | Required Fields | Optional Fields |
|---|---|---|
| Contact | email, message | name, subject |
| Volunteer | email, availability | name, skills, message |
| Newsletter | (preferences stored client-side only) | |
| Incident Report | description | type, location, date, email (anonymous by default) |
Q: Is Supabase safe for activist data? A: With client-side encryption enabled, Supabase stores only ciphertext for PII fields. Even Supabase employees cannot read the data. The encryption key never leaves your machine.
Q: What if I don't set up encryption? A: The site works fine without it. Supabase still provides RLS (insert-only access), TLS in transit, and AES-256 encryption at rest. Client-side encryption adds an extra layer.
Q: What if Supabase shuts down?
A: Export your data from the Supabase dashboard at any time. The SQL schema is in
SUPABASE_SETUP.md and can be recreated on any PostgreSQL database.
Q: Can I switch to Cloudflare D1 later?
A: Yes. The service layer (supabaseService.js) abstracts database access.
Replace the Supabase calls with D1 Worker calls and the rest of the app stays the same.
Q: What about email delivery (newsletters, notifications)? A: Email delivery is a separate concern (deferred). The newsletter form stores subscriptions; an email service (Resend, Postmark, or Mailgun free tier) can be added later.
The site uses a two-layer caching strategy for optimal performance.
Configured via public/_headers:
| Path | Cache-Control | Purpose |
|---|---|---|
/assets/* |
public, max-age=31536000, immutable |
Vite-hashed JS/CSS bundles (1 year, safe because filenames change on rebuild) |
/index.html |
no-cache |
Always fetch latest SPA shell (small file, ensures updates propagate) |
/sw.js |
no-cache |
Service worker must always be fresh |
/manifest.json |
public, max-age=86400 |
PWA manifest (1 day) |
How it works: Vite generates content-hashed filenames (e.g., index-CvqHDFfR.js). Since the hash changes on every build, it's safe to cache these files forever (immutable). The index.html entry point is never cached, so users always get the latest bundle references.
When the Express backend is deployed, feed API responses are cached using the built-in cacheService:
| Endpoint | TTL | Cache-Control | Tags |
|---|---|---|---|
GET /api/v1/feeds |
10 min | max-age=600 |
feeds |
GET /api/v1/feeds/sources |
30 min | max-age=1800 |
feeds, sources |
GET /api/v1/feeds/stats |
5 min | max-age=300 |
feeds, stats |
Features:
- TTL-based expiration — cache entries automatically expire
- Tag-based invalidation —
POST /api/v1/feeds/pollclears allfeedscache entries - LRU eviction — oldest entries removed when cache reaches 1000 entries
- X-Cache header — responses include
X-Cache: HITorX-Cache: MISSfor debugging
Cache service location: backend/src/services/cacheService.js
For distributed caching across Cloudflare's global edge network, Cloudflare KV can be added:
This would replace the in-memory cache with edge-distributed storage, beneficial when the backend scales beyond a single Worker instance. For the current deployment, the in-memory cache is sufficient.