CSP for WorkOS Authentication

Table of Contents

Content Security Policy gets awkward fast once authentication enters the picture.

A basic app can survive on default-src 'self' and a couple of explicit allowlists. Then you add WorkOS authentication, maybe a hosted sign-in page, maybe social login redirects, maybe passkeys, and suddenly people start punching holes in the policy until the browser stops complaining.

That usually works. It also usually leaves you with a CSP that looks secure but barely does anything.

I’d rather treat auth as a first-class CSP design problem. WorkOS is actually pretty clean from a CSP perspective, because most of the auth flow is redirect-based. That means you often need fewer exceptions than people expect.

What CSP actually needs for WorkOS

For a typical WorkOS integration, your app usually needs to handle:

  • your own sign-in button and login routes
  • redirects to WorkOS-hosted authentication
  • callback handling back on your app
  • optional embedded assets or scripts you add around auth pages
  • API calls from your frontend to your own backend, not directly to WorkOS in most cases

That last point matters. If your browser talks only to your app, and your app talks to WorkOS server-to-server, CSP stays tight.

A good starting point is this:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-{RANDOM_NONCE}' 'strict-dynamic';
  style-src 'self';
  img-src 'self' data:;
  font-src 'self';
  connect-src 'self';
  frame-ancestors 'none';
  base-uri 'self';
  form-action 'self';
  object-src 'none';

This is intentionally strict. It assumes:

  • no inline scripts unless nonce-protected
  • no third-party JavaScript by default
  • forms only submit back to your own origin
  • no framing

For many WorkOS setups, that’s enough.

Why redirect-based auth is CSP-friendly

A redirect to WorkOS does not require connect-src to include WorkOS. Browser navigation is not controlled by connect-src.

If your login button sends the user to /login, and your backend creates a WorkOS authorization URL and redirects the browser there, CSP does not need special allowances for that redirect.

Here’s a simple Node/Express example:

import express from "express";
import crypto from "node:crypto";

const app = express();

app.use((req, res, next) => {
  res.locals.nonce = crypto.randomUUID();
  res.setHeader(
    "Content-Security-Policy",
    [
      "default-src 'self'",
      `script-src 'self' 'nonce-${res.locals.nonce}' 'strict-dynamic'`,
      "style-src 'self'",
      "img-src 'self' data:",
      "font-src 'self'",
      "connect-src 'self'",
      "frame-ancestors 'none'",
      "base-uri 'self'",
      "form-action 'self'",
      "object-src 'none'"
    ].join("; ")
  );
  next();
});

app.get("/login", async (req, res) => {
  // Your server creates the WorkOS authorization URL
  const authorizationUrl = await getWorkOSAuthorizationUrl();
  res.redirect(302, authorizationUrl);
});

app.get("/callback", async (req, res) => {
  const code = req.query.code;
  const session = await exchangeCodeForSession(code);
  // Persist session, then redirect into app
  res.redirect("/app");
});

No WorkOS domain appears in CSP here, and that’s fine.

The place people break CSP: form submissions

If your app posts a form directly to a third-party auth endpoint, form-action becomes relevant. Most WorkOS apps should avoid that pattern and submit to their own backend instead.

Good:

<form method="post" action="/login/start">
  <button type="submit">Sign in</button>
</form>

Bad for CSP hygiene:

<form method="post" action="https://some-auth-domain.example.com/login">
  <button type="submit">Sign in</button>
</form>

If you post directly off-origin, you’ll need to loosen form-action. I’d avoid that unless you really need it.

A practical policy for a real app

Most production apps aren’t this clean. They have analytics, cookie banners, and random frontend tooling bolted on. The real header from headertest.com is a good example of that kind of policy:

content-security-policy:
default-src 'self' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
script-src 'self' 'nonce-NTg1ZjE1ZGMtODBiMy00MzNiLTk5NzItNWZjYTkzNGZkMDJm' 'strict-dynamic' https://www.googletagmanager.com https://*.cookiebot.com https://*.google-analytics.com;
style-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://*.cookiebot.com https://consent.cookiebot.com;
img-src 'self' data: https:;
font-src 'self';
connect-src 'self' https://api.headertest.com https://tallycdn.com https://or.headertest.com wss://or.headertest.com https://*.google-analytics.com https://*.googletagmanager.com https://*.cookiebot.com;
frame-src 'self' https://consentcdn.cookiebot.com;
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
object-src 'none'

This is realistic: auth isn’t the only thing happening on the page. If you add WorkOS to an app like this, don’t start by shoving WorkOS domains into every directive. Figure out which browser capability is actually used.

Usually:

  • redirects to WorkOS: no CSP change
  • callback back to your app: no CSP change
  • backend token exchange: no CSP change
  • frontend XHR/fetch directly to WorkOS: add WorkOS to connect-src
  • embedded iframe from WorkOS: add WorkOS to frame-src
  • direct form post to WorkOS: add WorkOS to form-action

That mapping saves a lot of guesswork.

Example: Next.js app with WorkOS and nonce-based CSP

Here’s a simple middleware approach for Next.js that sets a per-request nonce.

// middleware.ts
import { NextRequest, NextResponse } from "next/server";
import crypto from "node:crypto";

export function middleware(req: NextRequest) {
  const nonce = crypto.randomUUID();

  const csp = [
    "default-src 'self'",
    `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
    "style-src 'self'",
    "img-src 'self' data:",
    "font-src 'self'",
    "connect-src 'self'",
    "frame-ancestors 'none'",
    "base-uri 'self'",
    "form-action 'self'",
    "object-src 'none'"
  ].join("; ");

  const requestHeaders = new Headers(req.headers);
  requestHeaders.set("x-nonce", nonce);

  const response = NextResponse.next({
    request: {
      headers: requestHeaders
    }
  });

  response.headers.set("Content-Security-Policy", csp);
  return response;
}

Then use the nonce in your page:

// app/login/page.tsx
import { headers } from "next/headers";

export default async function LoginPage() {
  const nonce = (await headers()).get("x-nonce") || "";

  return (
    <main>
      <h1>Sign in</h1>
      <form method="post" action="/auth/login">
        <button type="submit">Continue with WorkOS</button>
      </form>

      <script
        nonce={nonce}
        dangerouslySetInnerHTML={{
          __html: `
            console.log("Login page loaded");
          `,
        }}
      />
    </main>
  );
}

And the route that starts auth:

// app/auth/login/route.ts
import { NextResponse } from "next/server";

export async function POST() {
  const authorizationUrl = await getWorkOSAuthorizationUrl();
  return NextResponse.redirect(authorizationUrl, 302);
}

Again, no WorkOS CSP allowlist needed just for the redirect flow.

When you do need to allow WorkOS origins

Some setups use frontend code that talks directly to WorkOS endpoints. If that’s your architecture, scope the allowance narrowly.

Example:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-{RANDOM_NONCE}' 'strict-dynamic';
  style-src 'self';
  img-src 'self' data:;
  font-src 'self';
  connect-src 'self' https://api.workos.com;
  frame-ancestors 'none';
  base-uri 'self';
  form-action 'self';
  object-src 'none';

If you render a WorkOS-hosted widget in an iframe, then add only what’s needed:

frame-src https://*.workos.com;

If a direct browser form post is genuinely required:

form-action 'self' https://*.workos.com;

Don’t cargo-cult all three into every app.

Passkeys and WebAuthn

If you use WorkOS with passkeys, most of the magic is still handled by the browser and the relying party flow, not by broad CSP exceptions. CSP usually doesn’t need special directives for WebAuthn itself.

The auth page still needs the same basics:

  • trusted scripts only
  • avoid inline JS unless nonce-protected
  • no random third-party script on the login page

I’m pretty aggressive here: auth pages should be some of the cleanest pages in your app. If marketing wants five trackers on the homepage, fine. Your sign-in page should stay boring.

Roll out with Report-Only first

If you’re tightening CSP around auth, start with Content-Security-Policy-Report-Only before enforcing.

Example:

Content-Security-Policy-Report-Only:
  default-src 'self';
  script-src 'self' 'nonce-{RANDOM_NONCE}' 'strict-dynamic';
  style-src 'self';
  img-src 'self' data:;
  font-src 'self';
  connect-src 'self';
  frame-ancestors 'none';
  base-uri 'self';
  form-action 'self';
  object-src 'none';

Watch for violations during:

  • sign-in redirect
  • callback handling
  • logout
  • passwordless or passkey flows
  • organization/domain discovery flows

You’ll usually find the breakage comes from your own analytics or UI libraries, not WorkOS.

A policy template I’d actually ship

For a server-rendered app using WorkOS redirects, I’d start here:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-{RANDOM_NONCE}' 'strict-dynamic';
  style-src 'self';
  img-src 'self' data:;
  font-src 'self';
  connect-src 'self';
  frame-ancestors 'none';
  base-uri 'self';
  form-action 'self';
  object-src 'none';

If the app also needs common third-party services, expand only the directives that need them. The headertest.com header is a decent model for that style: specific additions to script-src, style-src, connect-src, and frame-src, while keeping frame-ancestors, base-uri, form-action, and object-src locked down.

If you need ready-made policy patterns, https://csp-examples.com is handy for comparing strict and permissive setups. For WorkOS behavior and endpoints, check the official docs at https://workos.com/docs.

The main rule is simple: don’t loosen CSP just because auth is involved. WorkOS usually works best when your browser does less and your backend does more. That’s good architecture, and it keeps CSP sane.