# Security you can verify in the repo

> The home page now lists eight concrete protections. Here is where each one lives in the code, what it actually does, and what is still your job after you buy the template.

Most SaaS landing pages have a security section. It mentions encryption, compliance logos, and something about enterprise-grade infrastructure. You cannot tell whether any of it is true without signing an NDA.

CastorStack's home page lists eight specific protections. Each one maps to code you can open, tests that fail the build if someone removes it, or a script that runs before the buyer ZIP leaves the repo. This post walks through all eight.

Authentication and RLS on Supabase tables got their own article ([The cost of the first login](/en/blog/the-cost-of-the-first-login)). What follows is everything else on that list.

## Signed payment webhooks

Stripe, Paddle and Polar all deliver subscription events over HTTP POST. Anyone on the internet can hit that URL. The only thing standing between your database and a forged "payment succeeded" event is signature verification.

In CastorStack each provider has its own verifier: Stripe uses the standard webhook signature helper with HMAC-SHA256 and constant-time comparison; Paddle parses the `Paddle-Signature` header the same way; Polar checks `webhook-signature` and `webhook-timestamp`. All three reject events older than five minutes, which closes the replay window. Mismatched signatures fail with `FixedTimeEquals`, not a string comparison that leaks timing information.

Events are stored with an idempotency key before any business logic runs. A provider retrying the same event does not create a second subscription row.

You can read the implementations in `PaddlePaymentProvider.cs`, `StandardWebhookSignature.cs`, and the Polar provider. The tests in `PaddlePaymentProviderTests.cs` and `StripeWebhookEndpointsTests.cs` cover the failure cases.

## Admin routes gated end to end

The admin panel is a separate Vite app with its own deployment. That separation is useful, but it is not the security boundary. The boundary is the API.

Every endpoint under `/api/v1/admin/*` requires an authenticated user with the admin role. There is no "public admin read" shortcut and no endpoint that checks the role in one place but forgets it in another. The authorization attribute is applied route by route, and the tests assert that unauthenticated and non-admin callers get 401 or 403.

If you add a new admin endpoint and skip the attribute, you will notice quickly because the pattern is consistent across the existing controllers.

## Encrypted PSP credentials

Payment provider secrets (Stripe secret key, Paddle API key, webhook signing secrets) are stored in the database because the admin panel needs to configure them at runtime. Storing them in plain text would mean anyone with read access to Postgres could charge cards on your behalf.

CastorStack encrypts them with ASP.NET Data Protection before persistence. The encryption keys live in a `DataProtectionKeys` table, and that table has RLS enabled like every other server-managed table. API responses never return the decrypted secret; the admin UI shows a masked placeholder after the initial save.

Rotating keys or moving between environments requires understanding Data Protection's key ring, which is documented in the infrastructure setup. It is more moving parts than environment variables, but environment variables do not survive "configure this in the admin panel" as a product requirement.

## No raw SQL in the API

Entity Framework Core is the only database access path in the API project. There are no `FromSqlRaw` calls with interpolated strings, no Dapper queries, no hand-built SQL in controllers.

That is a deliberate constraint. Parameterized queries through EF eliminate the most common injection surface in a line-of-business API. When you need raw SQL, it belongs in a migration file where it is reviewed, versioned, and never built from user input.

## RLS enforced by migration

This one almost slipped past us, and it deserves more than a bullet point.

Supabase exposes every table in `public` through PostgREST. The anonymous key ships in your frontend bundle. Anyone can read it. Row Level Security is the actual protection, and Postgres does not enable it automatically on tables your migrations create.

CastorStack has a `HardenRowLevelSecurity` migration that enables RLS on every server-managed table, revokes grants from `anon` and `authenticated`, and changes the schema's default privileges so the next `CreateTable` is born closed. A regression test (`RowLevelSecurityCoverageTests`) parses every migration source and fails the build if a new table appears without a matching `ENABLE ROW LEVEL SECURITY` statement.

That test is the part I would not have thought to write until after someone pointed a REST client at production.

## Buyer ZIP scanning

When you package CastorStack for a buyer, `scripts/package-release.mjs` stages a ZIP and scans it before release. The scan looks for live Stripe keys, JWT-shaped secrets, hardcoded passwords, and personal email addresses that should not travel with the template.

If something matches, the script exits with an error and the ZIP does not ship. It is a last line of defense against "I committed a secret three months ago and forgot."

## Strict CORS defaults

The API's CORS policy lists explicit allowed origins from configuration. Credentials are disabled. That combination means a browser on a random domain cannot make authenticated cross-origin requests to your API, which rules out a class of CSRF attacks against cookie-based sessions.

You configure the allowed origins per environment (`Cors:AllowedOrigins` in appsettings or the equivalent environment variable). Adding a new frontend domain is a config change, not a code change.

## Checkout redirect allowlist

Checkout endpoints accept a `returnTo` URL so the user lands back on your app after payment. Without validation, that parameter becomes an open redirect: an attacker sends a victim through your checkout and out to a phishing page that looks like it came from you.

CastorStack validates `returnTo` against the same allowed origins as CORS. A URL on an unlisted domain returns 400. The tests in `PublicCheckoutEndpointsTests.cs` cover both the happy path and the rejection case.

## What is not here

This is not a SOC 2 certification. There is no WAF, no rate limiting middleware in the template, no automated dependency scanning in CI beyond what you add yourself. Penetration testing is your responsibility after you deploy.

Two-factor authentication is not implemented. Session listing and revocation are not implemented. The audit log records API-side changes, not Supabase login events.

The list on the home page is what ships in the repo today, not a roadmap dressed as a feature matrix. I would rather you find the gaps here than during a security review you did not schedule.

## Why list verifiable claims

Security marketing that cannot be checked erodes trust faster than no security section at all. A buyer who clones the repo and opens `RowLevelSecurityCoverageTests.cs` learns more about what they are getting than from any compliance badge.

If you are evaluating CastorStack, start with those eight items. Open the files. Run the tests. The home page is not asking you to take our word for it.
