Bastion

Bastion is a JSON API server in Rust that is hardened by default. This is how to run it, and how to put any frontend in front of it.

The server handles accounts, sessions, and a small CRUD resource. It hashes passwords with Argon2id, rotates single-use refresh tokens, rate-limits per IP and per account, and bounds every request and connection. None of that is optional or bolted on — it is wired in and covered by 151 tests, 31 of which attack the running server.

This site covers running it, what it costs at load, and wiring a frontend to it in whatever you already use: vanilla JavaScript, TypeScript, React, Vue, Svelte, Next.js, Tailwind, or plain CSS.

Quickstart

Rust 1.85 or newer. No database server — SQLite file, created on first run. PostgreSQL, MySQL and MongoDB are opt-in builds; the APP_DATABASE_URL scheme picks between them.

git clone https://github.com/umkara/bastion.git
cd bastion
cp .env.example .env
sed -i '' "s|^APP_JWT_SECRET=.*|APP_JWT_SECRET=$(openssl rand -base64 48)|" .env
mkdir -p data && cargo run

The server listens on 127.0.0.1:8443. It refuses to start without a signing key of at least 32 bytes, and refuses to start in production without TLS.

curl http://127.0.0.1:8443/health/ready
# {"status":"ready","version":"0.1.0"}

Create an account and log in — passwords are 12 characters minimum:

curl -X POST http://127.0.0.1:8443/api/v1/auth/register \
  -H 'content-type: application/json' \
  -d '{"email":"me@example.com","password":"correct-horse-battery-staple"}'

curl -X POST http://127.0.0.1:8443/api/v1/auth/login \
  -H 'content-type: application/json' \
  -d '{"email":"me@example.com","password":"correct-horse-battery-staple"}'

Performance

Measured, not estimated. Apple M2, 8 cores, release build, SQLite in WAL mode, loopback, ab -k with 20,000 requests at concurrency 50, median of three runs. Rate limits were raised for the run so the numbers reflect the server rather than the limiter.

Run-to-run variance is real. On a laptop these figures move by 10–20 % between runs, and by far more if the machine is busy — an earlier round taken right after a compile read three times lower. Take the orders of magnitude seriously and the exact digits lightly, and measure on your own hardware before planning capacity. The load generator is not the limit here — two parallel ab instances sum to the same total as one — but it does share the same eight cores as the server, so a dedicated machine would go higher.
53,136req/s — health probe
30,409req/s — authenticated read
2 msp50 — authenticated read
0failed requests
EndpointWork donereq/sp50p95p99
GET /health/live Routing and the full middleware stack 53,1361 ms1 ms2 ms
GET /api/v1/notes JWT verify + SQLite read 30,4092 ms2 ms3 ms
GET /style.css 5.9 KB static file from disk 7,6602 ms26 ms47 ms
POST /api/v1/auth/login Argon2id, deliberately expensive 73–187193 ms311 ms

Static files are the slowest thing here

A 5.9 KB stylesheet serves at roughly a quarter the rate of a JSON response, because every request reads it from disk — there is no in-memory asset cache. That is fine for a docs site or an internal tool. If you are serving a large bundle under real traffic, put a CDN or a reverse proxy in front and let the Rust server do what it is good at, which is the API.

Why login is slow on purpose

That last row is not a defect. Argon2id reserves 19 MiB and burns CPU by design — that is what makes stolen password hashes expensive to crack. A single login costs about 24 ms sequentially. Under concurrency the server admits only as many hashes at once as it has cores, so a login flood queues and sheds instead of exhausting memory. It is also the noisiest measurement here — saturating every core makes throughput swing with thermal state, hence the range rather than a single figure.

The number that matters: without that admission limit, 200 concurrent logins consumed 212 seconds of CPU and could have reserved gigabytes. With it, the same flood drains in 5.1 seconds and the excess is shed — while unrelated traffic keeps serving in single-digit milliseconds.

Reproduce it

cargo build --release
APP_RATE_LIMIT_BURST=1000000 APP_AUTH_RATE_LIMIT_BURST=1000000 \
  ./target/release/bastion

ab -k -c 50 -n 20000 http://127.0.0.1:8443/health/live
ab -k -c 50 -n 20000 -H "authorization: Bearer $TOKEN" \
  http://127.0.0.1:8443/api/v1/notes

Loopback numbers omit real network latency, and a production deployment adds TLS. Treat these as the ceiling the application imposes, not a promise about your infrastructure.

Serving a frontend

Point the server at a directory of built assets. Anything that does not match an API route is served from it, and unknown paths fall back to index.html so client-side routing works.

APP_STATIC_DIR=./dist cargo run

This page is served that way. Build your frontend however you like, then hand over the output directory — dist/ for Vite, build/ for Create React App, .output/public for Nuxt.

Same origin, no CORS. Serving the frontend from the same server means the browser never issues a cross-origin request, so you can leave APP_CORS_ALLOWED_ORIGINS empty. Only set it when the frontend is genuinely on another origin.

Or put a proxy in front

Equally valid, and common in production: let nginx or Caddy serve the static files and proxy /api to the server.

# Caddyfile
bastionrs.dev {
    handle /api/* {
        reverse_proxy 127.0.0.1:8443
    }
    handle {
        root * /srv/frontend
        try_files {path} /index.html
        file_server
    }
}

If you do this, set APP_TRUST_PROXY_HEADERS=true so rate limiting sees real client addresses instead of the proxy's — but only when the proxy is yours, since otherwise a client can forge the header and bypass the limit.

The API client

Every framework below shares the same core. The only part that needs care is token refresh: access tokens last 15 minutes, refresh tokens are single-use and rotate on every exchange. Store the newest one and never retry with an old one — replaying a spent token is treated as theft and revokes the whole session chain.

TypeScript

// api.ts — framework-agnostic, works in any of the setups below
const BASE = ""; // same origin when served by the Rust server

type Tokens = { access_token: string; refresh_token: string; expires_in: number };

let access: string | null = null;
let refresh: string | null = localStorage.getItem("refresh") ?? null;

function store(t: Tokens) {
  access = t.access_token;
  refresh = t.refresh_token;          // always the newest one
  localStorage.setItem("refresh", t.refresh_token);
}

export async function login(email: string, password: string) {
  const r = await fetch(`${BASE}/api/v1/auth/login`, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ email, password }),
  });
  if (!r.ok) throw await apiError(r);
  store(await r.json());
}

/** Fetch with the access token, refreshing once on 401. */
export async function api(path: string, init: RequestInit = {}): Promise<Response> {
  const send = () =>
    fetch(`${BASE}${path}`, {
      ...init,
      headers: {
        ...init.headers,
        ...(access ? { authorization: `Bearer ${access}` } : {}),
      },
    });

  let res = await send();
  if (res.status === 401 && refresh) {
    const r = await fetch(`${BASE}/api/v1/auth/refresh`, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ refresh_token: refresh }),
    });
    if (!r.ok) {          // refresh rejected: the session is over
      access = refresh = null;
      localStorage.removeItem("refresh");
      throw await apiError(r);
    }
    store(await r.json());
    res = await send();   // one retry, never a loop
  }
  return res;
}

async function apiError(r: Response) {
  const body = await r.json().catch(() => null);
  return new Error(body?.error?.message ?? `HTTP ${r.status}`);
}
One retry, not a loop. If the refresh itself fails, clear the tokens and send the user to the login screen. Retrying a rejected refresh in a loop will hammer the auth rate limit and lock the account out.

Vanilla JavaScript

The same file with the types removed — no build step, load it as a module.

<script type="module" src="/api.js"></script>

JS & TS frameworks

The first five build to static files, which is all the server needs: build, then point APP_STATIC_DIR at the output. Next.js is the exception — it runs as its own process and calls the API server-side.

No build step at all. Write files, serve the folder.

public/
  index.html
  api.js
  style.css

APP_STATIC_DIR=./public cargo run
<!-- index.html -->
<script type="module">
  import { login, api } from "/api.js";

  document.querySelector("#login").addEventListener("submit", async (e) => {
    e.preventDefault();
    await login(email.value, password.value);
    const notes = await (await api("/api/v1/notes")).json();
    render(notes.items);
  });
</script>

Note the module lives in a file rather than inline — the page CSP does not allow 'unsafe-inline', which is what stops an injected <script> from executing. See CSP & headers.

BetterAuth plugin

The Next.js tab hand-rolls its session in six files and needs no library. This is the other answer: keep BetterAuth for the browser session — its cookie, its CSRF, its route handler, its React hooks — and make this server the credential authority behind it. Nothing about the split changes. Bastion tokens still never reach the browser; the plugin is only the seam between the two.

Browser ──BetterAuth cookie──> Next (Node) ──Bearer token──> this server
                                   │
                                   └── bastionCredential: sealed token pair
ConcernOwner
Passwords, account records, refresh-token rotationThis server
Session cookie, CSRF, /api/auth/* handler, client hooksBetterAuth
Everything elseYou

A working copy is examples/nextjs/src/lib/bastion/ in the repository — thirteen files, one dependency beyond BetterAuth itself (zod), and exactly one line that knows where your database is. Copy the directory; the rest of this section is what is in it and why.

Install

pnpm add better-auth zod

On pnpm 10, better-sqlite3 also needs its postinstall unblocked, or the native binding is never compiled — and pin it to 12, because BetterAuth peer-requires ^12:

// package.json
"pnpm": { "onlyBuiltDependencies": ["better-sqlite3"] }

That block is pnpm's. npm and Yarn run install scripts by default and need nothing; Bun blocks them the same way pnpm does and reads "trustedDependencies": ["better-sqlite3"] instead. Whichever you use, the symptom of getting it wrong is identical — a missing .node binding at the first database call.

VariableDefaultNotes
BASTION_URLhttp://127.0.0.1:8080No trailing slash
BASTION_API_PREFIX/api/v1
BASTION_TOKEN_SECRETrequired32 bytes base64; seals tokens at rest
BASTION_TIMEOUT_MS8000
BASTION_REFRESH_SKEW_SECONDS30Raise it to force a refresh on every call while testing
BASTION_REFRESH_LEASE_MS15000How long one refresh may hold the lock
BASTION_FORWARD_CLIENT_IPfalseSee Rate limits below

Parse that at module load and throw on anything invalid. A missing token secret should stop the process at boot, not surface as a decrypt failure on somebody's first sign-in three hours later.

The config, and the one setting that must stay off

// src/lib/auth.ts
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { nextCookies } from "better-auth/next-js";

import { db, schema } from "@/db";
import { bastion } from "@/lib/bastion";

export const auth = betterAuth({
  database: drizzleAdapter(db, { provider: "sqlite", schema }),
  secret: process.env.BETTER_AUTH_SECRET,

  emailAndPassword: { enabled: false },

  user: {
    additionalFields: {
      bastionUserId: { type: "string", required: false, input: false },
      role: { type: "string", required: false, input: false, defaultValue: "user" },
    },
  },

  // nextCookies() must be last — it wraps the response so Set-Cookie survives
  // Next's server-action boundary.
  plugins: [bastion(), nextCookies()],
});
emailAndPassword must stay disabled. Leaving it on mounts /sign-up/email, /forget-password and /reset-password, which write password hashes into BetterAuth's own account table. This server would know nothing about them, and a user who reset their password there would find it unchanged everywhere else. Passwords get one owner.

input: false on both added fields is not decoration either. Without it BetterAuth accepts them from a request body, and a browser can hand itself role: "admin" or point its local user at somebody else's account here. They are only ever written server-side, from claims.

What the plugin adds

RouteBodyCalls out
POST /api/auth/sign-in/bastion{email, password}1
POST /api/auth/sign-up/bastion{email, password}2 — register returns no tokens, so a login follows
POST /api/auth/change-password/bastion{currentPassword, newPassword}2 — see below
POST /api/auth/sign-out1, from a before hook

Sign-in is the shape all of them share: authenticate here, mirror the identity into BetterAuth's user table, mint a BetterAuth session, stash the token pair against that session id.

// src/lib/bastion/plugin.ts
import type { BetterAuthPlugin } from "better-auth";
import { APIError, createAuthEndpoint } from "better-auth/api";
import { setSessionCookie } from "better-auth/cookies";
import * as z from "zod";

export const bastion = () =>
  ({
    id: "bastion",
    schema: bastionSchema,

    endpoints: {
      signInBastion: createAuthEndpoint(
        "/sign-in/bastion",
        { method: "POST", body: z.object({ email: z.string(), password: z.string() }) },
        async (ctx) => {
          const email = ctx.body.email.trim().toLowerCase();

          try {
            const issued = await api.login({ email, password: ctx.body.password });
            const claims = decodeAccessTokenClaimsUnverified(issued.access_token);

            const user = await upsertUser(ctx, {
              bastionUserId: claims.sub, email, role: claims.role,
            });

            const session = await ctx.context.internalAdapter.createSession(user.id);
            tokens.persist({ sessionId: session.id, bastionUserId: claims.sub, tokens: issued });

            await setSessionCookie(ctx, { session, user });
            return ctx.json({ token: session.token, user });
          } catch (error) {
            return toApiError(error);   // maps 401/422/429 onto APIError
          }
        },
      ),
    },
  }) satisfies BetterAuthPlugin;

Two details in there are this server's, not BetterAuth's. The email is normalised trim().toLowerCase() because that is what happens here before both lookup and insert — skip it and one account here becomes two local users. And the email comes from the form, not the token: the access token carries sub, role, exp, iat, jti and no email claim, and there is no /me to ask.

upsertUser looks the local user up through the account table on the uuid rather than matching on email — email is mutable in principle, the uuid is not — and rewrites role on every sign-in, so a promotion here takes effect at the user's next login with no sync job anywhere.

Sign-out needs no endpoint of its own, just a hook that runs first:

hooks: {
  before: [{
    // Before, not after: once BetterAuth drops the session row there is no
    // way back to the credential.
    matcher: (context) => context.path === "/sign-out",
    handler: createAuthMiddleware(async (ctx) => {
      const current = await getSessionFromCtx(ctx);
      if (current?.session) await tokens.revoke(current.session.id);
    }),
  }],
}

One credential row per session, not per user

BetterAuth's account table is the obvious home and the wrong one: it is keyed by (user, provider), and this server mints a separate refresh-token family per login. Two devices sharing a row would rotate each other's token away, which reads as replay here and revokes the family.

// src/lib/bastion/schema.ts
export const bastionSchema = {
  user: {
    fields: {
      bastionUserId: { type: "string", required: false, input: false },
      role: { type: "string", required: false, input: false, defaultValue: "user" },
    },
  },
  bastionCredential: {
    fields: {
      sessionId: {
        type: "string", required: true, unique: true,
        references: { model: "session", field: "id", onDelete: "cascade" },
      },
      bastionUserId: { type: "string", required: true },
      accessToken:  { type: "string", required: true },   // AES-256-GCM sealed
      refreshToken: { type: "string", required: true },   // AES-256-GCM sealed
      accessTokenExpiresAt: { type: "date", required: true },
      generation:  { type: "number", required: true, defaultValue: 0 },
      lockedUntil: { type: "date", required: false },
      status: { type: "string", required: true, defaultValue: "active" },
      createdAt: { type: "date", required: true },
      updatedAt: { type: "date", required: true },
    },
  },
} satisfies PluginDBSchema;
pnpm dlx @better-auth/cli generate

Seal both token columns yourself. BetterAuth's encryptOAuthTokens only covers its account table, so a refresh token sitting here in plaintext is a standing account takeover for anyone who gets a copy of the file.

Reach the schema type through BetterAuthPlugin. PluginDBSchema above is NonNullable<BetterAuthPlugin["schema"]>, not an import from @better-auth/core/db where it actually lives. That package is a transitive dependency, and pnpm or Yarn PnP will not resolve it from your app — which would cost the directory its one virtue, being copy-pasteable.

The client half

Types only. Bastion tokens never reach the browser, so there is nothing for a client plugin to do at runtime — it exists so authClient.signIn.bastion(…) type-checks.

// src/lib/bastion/client.ts
import type { BetterAuthClientPlugin } from "better-auth";
import type { bastion } from "./plugin";

export const bastionClient = () =>
  ({ id: "bastion", $InferServerPlugin: {} as ReturnType<typeof bastion> })
    satisfies BetterAuthClientPlugin;
// src/lib/auth-client.ts
"use client";
import { createAuthClient } from "better-auth/react";
import { bastionClient } from "./bastion/client";

export const authClient = createAuthClient({ plugins: [bastionClient()] });
export const { useSession, signOut } = authClient;

Calling it from a server action

Going through auth.api.* rather than the client keeps sign-in to one round trip, which matters if anything else has to happen in the same request — merging a guest cart, say.

// src/app/(auth)/actions.ts
"use server";

import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { APIError } from "better-auth/api";

import { auth } from "@/lib/auth";

export async function signInAction(_state: FormState, form: FormData) {
  let userId: string;

  try {
    const result = await auth.api.signInBastion({
      body: { email: String(form.get("email")), password: String(form.get("password")) },
      headers: await headers(),
      asResponse: false,
    });
    userId = result.user.id;   // NOT getSession() — see below
  } catch (error) {
    return { error: error instanceof APIError ? error.message : "Something went wrong." };
  }

  await mergeGuestCart(userId);
  redirect("/products");
}
The session cookie is not readable in the action that creates it. setSessionCookie writes to the response; the request headers this action was called with still describe an anonymous visitor, so a following auth.api.getSession() reports nobody and any work keyed on the user silently does nothing. Take the id from the endpoint's return value. Also leave requireRequest off your endpoints — it makes auth.api.* callers pass a request, which server actions do not have.

Using a token in your own code

withAccessToken refreshes if the token is close to expiry and retries once on an unexpected 401 — a clock skew, or a revocation that landed between the expiry check and the call.

import { withAccessToken, CredentialRevoked } from "@/lib/bastion";

try {
  await withAccessToken(session.id, (token) =>
    fetch(`${base}/api/v1/notes`, { headers: { authorization: `Bearer ${token}` } }),
  );
} catch (error) {
  if (error instanceof CredentialRevoked) {
    // sign the user out; this is not a 500
  }
}

Turn the retry off where a 401 is not about the token. /auth/password is the case: a wrong current password answers 401 exactly like a stale token does, so the default rule spends a rotation to rediscover the same 401.

await withAccessToken(session.id, (token) => api.changePassword(token, { … }),
  { retryOnUnauthorized: false });

That endpoint then has a second consequence: a successful password change revokes every session on the account here, including the one that made the call. The plugin logs back in with the new password and swaps the stored credential in place, so the BetterAuth session survives against a new token family. Doing nothing would leave the user holding a dead session.

Refresh is lazy, and that is load-bearing. The BetterAuth session already carries bastionUserId, email and role, so rendering a page needs no call to this server at all — only auth transitions and the odd privileged action go out. Every request from your app arrives from one IP and shares one rate-limit bucket; a proactive background refresh would multiply that traffic by your user count. Keep your session module structurally unable to reach the network.

The refresh lease

Same problem as the hand-rolled version, same solution, and worth repeating because it is the one part you cannot simplify away. Refresh tokens are single-use and losing the rotation race revokes the whole family, so two concurrent refreshes sign the user out everywhere. A promise map in module scope does not fix it: next dev compiles route handlers in separate workers and production runs more than one instance.

The lock is a compare-and-swap on generation. The winner is whoever changes exactly one row.

// src/lib/bastion/store.ts
export function acquireLease(input: {
  sessionId: string; observedGeneration: number; leaseMs: number;
}): boolean {
  const now = Date.now();
  const result = sqlite.prepare(
    `UPDATE "bastionCredential"
        SET lockedUntil = ?, updatedAt = ?
      WHERE sessionId = ?
        AND generation = ?
        AND status = 'active'
        AND (lockedUntil IS NULL OR lockedUntil < ?)`,
  ).run(now + input.leaseMs, now, input.sessionId, input.observedGeneration, now);

  return result.changes === 1;
}

A loser polls until the generation moves, bounded by the lease so a crashed holder cannot block anyone forever. Committing a rotation bumps generation, which releases the lease and invalidates every other holder's compare-and-swap in the same statement. The in-process promise map on top of this is an optimisation — delete it and you are slower but still correct; delete the lease and you are neither.

Failures split three ways, and collapsing them is how sessions die:

OutcomeTokenDo
429Unspent — the limiter rejected it before the handlerRelease the lease, let the next caller try
401Definitively dead — replayed, logged out, password changedMark revoked, sign the user out
Timeout / 5xxUnknown — may have been consumed with the response lostMark poisoned, never retry

poisoned costs one session. Retrying a token that was in fact consumed looks like replay and costs every session in the family, which is the trade that rule exists to make.

Rate limits

The /auth/* bucket here defaults to 5/s, burst 5, and your whole app shares it. Pace your own calls just under that — 4/s — so your client-side bucket empties first and a burst becomes a short wait instead of a 429. For per-end-user buckets set BASTION_FORWARD_CLIENT_IP=true, but only when this server runs behind a proxy you control with APP_TRUST_PROXY_HEADERS=true; otherwise the header is either ignored or spoofable.

Copying it

Take examples/nextjs/src/lib/bastion/ whole. Three edits and it runs:

  • store.tsimport { sqlite } from "@/db" is the only line that knows where your database lives.
  • Your BetterAuth config — add bastion() and turn emailAndPassword off.
  • Your auth client — add bastionClient().

Then pnpm dlx @better-auth/cli generate for the tables. The example runs against Next 16.2 and better-auth 1.6.25:

cd examples/nextjs
pnpm install
cp .env.example .env.local   # fill in the two secrets
pnpm db:push && pnpm db:seed && pnpm dev

CSS frameworks

Anything that compiles to a stylesheet works, because the server only serves files. The one rule: the output must be a file, not injected inline, since the page CSP does not permit inline styles.

Nothing to configure. A stylesheet in the served directory, linked normally.

<link rel="stylesheet" href="/style.css">

This page is exactly that: one hand-written stylesheet, no build step, no framework. Its source is bastion/style.css in the repository.

Endpoints

Base path /api/v1, except the probes.

MethodPathAuthBody
GET/health/live
GET/health/ready
POST/api/v1/auth/register{email, password}
POST/api/v1/auth/login{email, password}
POST/api/v1/auth/refresh{refresh_token}
POST/api/v1/auth/logout{refresh_token}
POST/api/v1/auth/passwordBearer{current_password, new_password}
GET / POST/api/v1/notesBearer{title, body}
GET / PUT / DELETE/api/v1/notes/{id}Bearer{title, body}
DELETE/api/v1/admin/users/{id}/sessionsBearer (admin)

Errors

One shape everywhere, so clients branch on code rather than prose.

{
  "error": {
    "code": "validation_failed",
    "message": "request validation failed",
    "details": [ { "field": "email", "message": "must be a valid email address" } ]
  }
}
StatuscodeWhat your UI should do
400validation_failedShow details against the named fields
401unauthorizedRefresh once; if that fails, show the login screen
403forbiddenHide the action — the user is not permitted
404not_foundTreat as absent; it may exist but not be yours
409conflictEmail already registered
429Back off; do not retry in a tight loop
503service_unavailableServer shedding load; retry with backoff
Two deliberate behaviours. Someone else's resource returns 404, not 403 — a 403 would confirm the id exists. And every login failure returns the same 401: wrong password, unknown address, and locked account are indistinguishable by status, body, and timing. Do not write UI copy that claims to know which it was.

CSP & headers

The server sends two different policies, because an API and a web page need different things and merging them weakens both.

API routesServed pages
Content-Security-Policy default-src 'none'; sandbox default-src 'self', no inline
Cache-Controlno-storepublic, max-age=3600

Both get the same baseline on every response:

strict-transport-security: max-age=31536000; includeSubDomains; preload
x-content-type-options: nosniff
x-frame-options: DENY
referrer-policy: no-referrer
cross-origin-opener-policy: same-origin
cross-origin-resource-policy: same-origin
permissions-policy: camera=(), geolocation=(), microphone=(), payment=(), usb=()
x-request-id: <uuid, echoed back for correlation>

What this means for your frontend

  • No inline scripts or styles. No <script>alert(1)</script>, no style="…" attributes, no onclick= handlers. Put code in files and attach listeners in JavaScript. Every bundler already does this.
  • Same-origin only. No CDN scripts, fonts, or images. Install the dependency and let your bundler emit it locally.
  • The document revalidates, assets are cached. index.html is served no-cache so a deploy takes effect immediately; everything else gets an hour. Cache the document instead and users keep running the previous build until it expires.
  • Tokens live in memory, refresh tokens in localStorage. With inline execution blocked and frame-ancestors 'none' set, the usual paths to stealing them are closed.

If a framework insists on inline styles, prefer configuring it to emit a file over relaxing the policy. 'unsafe-inline' removes most of what CSP is protecting you from.