← all posts
Building a PMS SaaS · Part 2

Building a PMS SaaS, Part 2: the security holes in my own POC

In Part 1 I decided to turn a shelved client POC into a product I own. The first real engineering step wasn’t a feature — it was reading my own code as an attacker would, because a POC for one trusted business and a SaaS holding many businesses’ data are not the same program.

I found seven things. Two of them were bad. Here’s the honest list.

1. Anyone could register as admin

The registration endpoint accepted a permissions array from the request body and saved it straight onto the new user:

const { email, password, firstName, lastName, permissions } = req.body;
const user = new User({ email, password, firstName, lastName, permissions });

So POST /api/auth/register with "permissions": ["admin"] created an admin. Full takeover, from the public signup form. In a single-tenant app you trust everyone who has a login; in a product, registration is the front door for strangers. New accounts now start with permissions: [] and an admin grants access later.

2. An entire resource had no auth at all

The properties routes were mounted with no middleware:

router.get('/', getProperties);
router.post('/', createProperty);
// ...no authMiddleware, no permission check

Anyone on the internet could read, create, edit, and delete properties. The rooms and bookings routes were correctly protected — properties just got missed, and in a demo where you only ever hit it through the authed UI, you never notice. Now it has authMiddleware plus properties:* permission checks like everything else. The transactions routes had auth but were missing the permission layer — same fix.

3. The JWT secret had a hardcoded fallback — that was actually being used

const JWT_SECRET = process.env.JWT_SECRET || 'supersecretjwtkey';

Two problems. One, the fallback is a publicly-known string that can forge any token. Two — and this is the subtle one — controllers read JWT_SECRET at module load time, which happened before dotenv.config() ran in the server entry point. So even with a real secret in .env, the weak fallback could silently be the one in use.

The fix was a single config.ts that loads env first and refuses to start if JWT_SECRET is missing — fail fast and loud, never fall back to something insecure:

function required(name: string): string {
  const v = process.env[name];
  if (!v) throw new Error(`FATAL: missing required env var ${name}`);
  return v;
}
export const JWT_SECRET = required('JWT_SECRET');

4. A public, unauthenticated webhook

The channel-manager webhook accepted any POST and triggered a sync. Anyone who knew the URL could spam syncs and flood the log table. It now requires a secret token in the URL (?token=...), compared in constant time, and the endpoint stays disabled (503) until the secret is configured — fail closed, not open.

5. The same admin password in every deployment

The seed logic created admin@example.com / password123 on first run. Fine for local dev, a disaster if it ships. Now the credentials come from env, and if no password is set, a random one is generated and printed exactly once.

6. No brute-force protection

The login endpoint would happily accept unlimited guesses. Added a rate limit (20 attempts per 15 minutes) on the credential endpoints — and only those, so a dashboard polling the user’s profile never gets throttled.

7. A duplicated permission condition hiding a real gap

While reading the permission middleware I found a copy-paste bug — bookings:all checked twice in one OR chain, while the actual missing case (a room editor needs to read properties, because the room form has a property dropdown) wasn’t handled. Fixed the logic and added the cross-resource grant.

Then I wrote tests so they can’t come back

A fix you can’t prove stays fixed will regress. I split the Express app out from the server bootstrap so it could be mounted in tests, and wrote 26 integration tests (Vitest + supertest) against a real MongoDB in an isolated database. The security ones are regression guards — there’s a test that registers with "permissions": ["admin"] and asserts the saved user has []. If anyone ever reintroduces that bug, the suite goes red.

The lesson

None of these were exotic. They’re the boring, well-known classes — missing authz, weak secrets, no rate limiting. They survived precisely because the app worked perfectly as a demo. The bugs only became visible when the threat model changed from “my friend uses this” to “strangers on the internet use this.” That shift in perspective was the actual work; the patches were easy once I was looking for the right thing.

Next part: multi-tenancy — the real heavy lift, where one app learns to serve many businesses without ever leaking one’s data to another.