diff --git a/docs/nix_keyserver_deployment.md b/docs/nix_keyserver_deployment.md
--- a/docs/nix_keyserver_deployment.md
+++ b/docs/nix_keyserver_deployment.md
@@ -14,6 +14,7 @@
 COMM_JSONCONFIG_secrets_user_credentials='{"username":"<user>","password":"<password>"}'
 COMM_JSONCONFIG_facts_landing_url='{"baseDomain":"http://localhost","basePath":"/commlanding/","baseRoutePath":"/commlanding/","https":false}'
 COMM_JSONCONFIG_facts_commapp_url='{"baseDomain":"http://localhost:3000","basePath":"/comm/","https":false,"baseRoutePath":"/comm/","proxy":"none"}'
+COMM_JSONCONFIG_facts_webapp_cors='{"domain": "http://localhost:3000"}'
 
 # Required to connect to production Identity service
 COMM_JSONCONFIG_secrets_identity_service_config="{\"identitySocketAddr\":\"https://identity.commtechnologies.org:50054\"}"
@@ -51,6 +52,11 @@
   - `proxy`: `"none" | "apache"` Determines which request headers to use for HTTPS validation and IP address timezone detection.
   - `https`: If true, checks request headers to validate that HTTPS is in use.
 
+### CORS configuration
+
+- `COMM_JSONCONFIG_facts_webapp_cors`: Your keyserver needs to be able to include CORS headers with the domain where the comm web application is hosted.
+  - `domain`: Domain where the web application is hosted.
+
 ### Backup configuration
 
 - `COMM_JSONCONFIG_facts_backups`: Specifies whether to enable backups, where to store them, and the max size of the backups directory.
diff --git a/keyserver/flow-typed/npm/cors_v2.x.x.js b/keyserver/flow-typed/npm/cors_v2.x.x.js
new file mode 100644
--- /dev/null
+++ b/keyserver/flow-typed/npm/cors_v2.x.x.js
@@ -0,0 +1,26 @@
+// flow-typed signature: 425712a647645fb8847dbd9109337837
+// flow-typed version: c6154227d1/cors_v2.x.x/flow_>=v0.104.x
+
+// @flow
+
+type CustomOrigin = (
+  requestOrigin: string,
+  callback: (err: Error | null, allow?: boolean) => void
+) => void;
+
+type CorsOptions = {
+  origin?: boolean | string | RegExp | string[] | RegExp[] | CustomOrigin,
+  methods?: string | string[],
+  allowedHeaders?: string | string[],
+  exposedHeaders?: string | string[],
+  credentials?: boolean,
+  maxAge?: number,
+  preflightContinue?: boolean,
+  optionsSuccessStatus?: number,
+  ...
+}
+
+declare module "cors" {
+  import type { $Request as Request, $Response as Response, NextFunction } from "express";
+  declare module.exports: (options?: CorsOptions) => (req: Request, res: Response, next?: NextFunction) => mixed;
+}
diff --git a/keyserver/package.json b/keyserver/package.json
--- a/keyserver/package.json
+++ b/keyserver/package.json
@@ -21,6 +21,7 @@
     "test": "jest"
   },
   "devDependencies": {
+    "0x": "^5.7.0",
     "@babel/cli": "^7.13.14",
     "@babel/core": "^7.13.14",
     "@babel/node": "^7.13.13",
@@ -41,8 +42,7 @@
     "flow-typed": "^3.2.1",
     "internal-ip": "4.3.0",
     "jest": "^26.6.3",
-    "nodemon": "^2.0.4",
-    "0x": "^5.7.0"
+    "nodemon": "^2.0.4"
   },
   "dependencies": {
     "@babel/runtime": "^7.13.10",
@@ -54,6 +54,7 @@
     "common-tags": "^1.7.2",
     "compression": "^1.7.4",
     "cookie-parser": "^1.4.3",
+    "cors": "^2.8.5",
     "dateformat": "^3.0.3",
     "detect-browser": "^4.0.4",
     "ethers": "^5.7.2",
diff --git a/keyserver/src/database/migration-config.js b/keyserver/src/database/migration-config.js
--- a/keyserver/src/database/migration-config.js
+++ b/keyserver/src/database/migration-config.js
@@ -615,6 +615,18 @@
       await dbQuery(query);
     },
   ],
+  [
+    49,
+    async () => {
+      if (isDockerEnvironment()) {
+        return;
+      }
+      const defaultCorsConfig = {
+        domain: 'http://localhost:3000',
+      };
+      writeJSONToFile(defaultCorsConfig, 'facts/webapp_cors.json');
+    },
+  ],
 ]);
 const newDatabaseVersion: number = Math.max(...migrations.keys());
 
@@ -680,7 +692,7 @@
   filePath: string,
   routePath: string,
 ): Promise<void> {
-  if (process.env.COMM_DATABASE_HOST) {
+  if (isDockerEnvironment()) {
     return;
   }
   // Since the non-Apache config is so opinionated, just write expected config
@@ -696,7 +708,7 @@
 }
 
 async function writeSquadCalRoute(filePath: string): Promise<void> {
-  if (process.env.COMM_DATABASE_HOST) {
+  if (isDockerEnvironment()) {
     return;
   }
   // Since the non-Apache config is so opinionated, just write expected config
@@ -736,4 +748,8 @@
   );
 }
 
+function isDockerEnvironment(): boolean {
+  return !!process.env.COMM_DATABASE_HOST;
+}
+
 export { migrations, newDatabaseVersion, createOlmAccounts };
diff --git a/keyserver/src/keyserver.js b/keyserver/src/keyserver.js
--- a/keyserver/src/keyserver.js
+++ b/keyserver/src/keyserver.js
@@ -4,6 +4,7 @@
 import cluster from 'cluster';
 import compression from 'compression';
 import cookieParser from 'cookie-parser';
+import cors from 'cors';
 import crypto from 'crypto';
 import express from 'express';
 import expressWs from 'express-ws';
@@ -46,12 +47,18 @@
   getSquadCalURLFacts,
   getLandingURLFacts,
   getCommAppURLFacts,
+  getWebAppCorsConfig,
 } from './utils/urls.js';
 
 const shouldDisplayQRCodeInTerminal = false;
 
 (async () => {
-  await Promise.all([olm.init(), prefetchAllURLFacts(), initENSCache()]);
+  const [webAppCorsConfig] = await Promise.all([
+    getWebAppCorsConfig(),
+    olm.init(),
+    prefetchAllURLFacts(),
+    initENSCache(),
+  ]);
 
   const squadCalBaseRoutePath = getSquadCalURLFacts()?.baseRoutePath;
   const landingBaseRoutePath = getLandingURLFacts()?.baseRoutePath;
@@ -62,6 +69,14 @@
       ? undefined
       : { maxAge: '1y', immutable: true };
 
+  let keyserverCorsOptions = null;
+  if (webAppCorsConfig) {
+    keyserverCorsOptions = {
+      origin: webAppCorsConfig.domain,
+      methods: ['GET', 'POST'],
+    };
+  }
+
   const isCPUProfilingEnabled = process.env.KEYSERVER_CPU_PROFILING_ENABLED;
   const areEndpointMetricsEnabled =
     process.env.KEYSERVER_ENDPOINT_METRICS_ENABLED;
@@ -226,6 +241,9 @@
 
     if (squadCalBaseRoutePath) {
       const squadCalRouter = express.Router();
+      if (keyserverCorsOptions) {
+        squadCalRouter.use(cors(keyserverCorsOptions));
+      }
       setupAppRouter(squadCalRouter);
       server.use(squadCalBaseRoutePath, squadCalRouter);
     }
diff --git a/keyserver/src/uploads/uploads.js b/keyserver/src/uploads/uploads.js
--- a/keyserver/src/uploads/uploads.js
+++ b/keyserver/src/uploads/uploads.js
@@ -172,12 +172,6 @@
     const { content, mime } = await fetchUpload(viewer, uploadID, secret);
     res.type(mime);
     res.set('Cache-Control', 'public, max-age=31557600, immutable');
-    if (process.env.NODE_ENV === 'development') {
-      // Add a CORS header to allow local development using localhost
-      const port = process.env.PORT || '3000';
-      res.set('Access-Control-Allow-Origin', `http://localhost:${port}`);
-      res.set('Access-Control-Allow-Methods', 'GET');
-    }
     res.send(content);
   } else {
     const totalUploadSize = await getUploadSize(uploadID, secret);
@@ -207,12 +201,6 @@
       'Content-Type': mime,
       'Content-Length': respWidth.toString(),
     };
-    if (process.env.NODE_ENV === 'development') {
-      // Add a CORS header to allow local development using localhost
-      const port = process.env.PORT || '3000';
-      respHeaders['Access-Control-Allow-Origin'] = `http://localhost:${port}`;
-      respHeaders['Access-Control-Allow-Methods'] = 'GET';
-    }
 
     // HTTP 206 Partial Content
     // https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/206
diff --git a/keyserver/src/utils/urls.js b/keyserver/src/utils/urls.js
--- a/keyserver/src/utils/urls.js
+++ b/keyserver/src/utils/urls.js
@@ -84,6 +84,15 @@
   return urlFacts;
 }
 
+export type WebAppCorsConfig = { +domain: string };
+async function getWebAppCorsConfig(): Promise<?WebAppCorsConfig> {
+  const config = await getCommConfig<WebAppCorsConfig>({
+    folder: 'facts',
+    name: 'webapp_cors',
+  });
+  return config;
+}
+
 export {
   prefetchAllURLFacts,
   getSquadCalURLFacts,
@@ -92,4 +101,5 @@
   getLandingURLFacts,
   getAndAssertLandingURLFacts,
   getAppURLFactsFromRequestURL,
+  getWebAppCorsConfig,
 };
diff --git a/scripts/create_url_facts.sh b/scripts/create_url_facts.sh
--- a/scripts/create_url_facts.sh
+++ b/scripts/create_url_facts.sh
@@ -8,6 +8,7 @@
 COMMAPP_URL_PATH="${KEYSERVER_FACTS_DIR}/commapp_url.json"
 LANDING_URL_PATH="${KEYSERVER_FACTS_DIR}/landing_url.json"
 SQUADCAL_URL_PATH="${KEYSERVER_FACTS_DIR}/squadcal_url.json"
+WEBAPP_CORS_PATH="${KEYSERVER_FACTS_DIR}/webapp_cors.json"
 
 if [[ ! -d "$KEYSERVER_FACTS_DIR" ]]; then
   mkdir -p "$KEYSERVER_FACTS_DIR"
@@ -24,3 +25,7 @@
 if [[ ! -f "$SQUADCAL_URL_PATH" ]]; then
   cp "$SCRIPT_DIR/templates/squadcal_url.json" "$SQUADCAL_URL_PATH"
 fi
+
+if [[ ! -f "$WEBAPP_CORS_PATH" ]]; then
+  cp "$SCRIPT_DIR/templates/webapp_cors.json" "$WEBAPP_CORS_PATH"
+fi
diff --git a/scripts/templates/webapp_cors.json b/scripts/templates/webapp_cors.json
new file mode 100644
--- /dev/null
+++ b/scripts/templates/webapp_cors.json
@@ -0,0 +1,3 @@
+{
+  "domain": "http://localhost:3000"
+}
diff --git a/yarn.lock b/yarn.lock
--- a/yarn.lock
+++ b/yarn.lock
@@ -13454,9 +13454,9 @@
   integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==
 
 globals@^13.20.0, globals@^13.6.0, globals@^13.9.0:
-  version "13.21.0"
-  resolved "https://registry.yarnpkg.com/globals/-/globals-13.21.0.tgz#163aae12f34ef502f5153cfbdd3600f36c63c571"
-  integrity sha512-ybyme3s4yy/t/3s35bewwXKOf7cvzfreG2lH0lZl0JB7I4GxRP2ghxOK/Nb9EkRXdbBXZLfq/p/0W2JUONB/Gg==
+  version "13.22.0"
+  resolved "https://registry.yarnpkg.com/globals/-/globals-13.22.0.tgz#0c9fcb9c48a2494fbb5edbfee644285543eba9d8"
+  integrity sha512-H1Ddc/PbZHTDVJSnj8kWptIRSD6AM3pK+mKytuIVF4uoBV7rshFlhhvA58ceJ5wp3Er58w6zj7bykMpYXt3ETw==
   dependencies:
     type-fest "^0.20.2"