The obvious objection

Open the network tab on almost any SaaS product and you'll find a POST /register somewhere. PropertyOS doesn't have one. No sign-up form, no "create an account" link, no public endpoint anywhere that turns an email address into a session. Every account on the platform — HQLead operator, landlord, landlord admin — is created by someone else, on the inside, before the person it belongs to ever sees the product.

To someone skimming the API that reads like an unfinished feature. It's the opposite: it's the first architectural decision the system made, and most of what follows in this series is downstream of it.

The business shape decides the access model

PropertyOS isn't a marketplace people discover and sign themselves up for. It's the internal system a property management company — HQLead — uses to run its own client relationships. A landlord doesn't find PropertyOS and register. HQLead onboards them, the way a bank onboards a customer rather than letting strangers open accounts from a public form.

So the access model follows the real relationship instead of fighting it. HQLead creates a landlord record, and that single action cascades into a fully provisioned landlord admin account — with no human ever typing a password into a form:

// landlordadminIdentity.service.js — createForLandlord (HQLead-only)
const tempPassword = passwordService.generateTemporaryPassword();
const passwordHash = await passwordService.hashPassword(tempPassword);

const passwordChangeToken = tokenService.generatePasswordChangeToken();
const passwordChangeTokenHash = await passwordService.hashToken(passwordChangeToken);

await landlordadminAuthRepo.create({
  landlord_admin_id: adminIdentity.id,
  password_hash: passwordHash,
  password_change_token: passwordChangeTokenHash,
  password_change_token_expires_at: in7Days,
  must_change_password: true
});

const activationLink = linkService.buildLink("/onboarding/landlord-admin/activate", {
  admin: adminIdentity.id,
  token: passwordChangeToken
});

The landlord admin's first interaction with PropertyOS is clicking a link and setting their own password — never touching, or even seeing, the temporary one. must_change_password makes that step mandatory rather than a suggestion.

Activation and password reset are the same code path

This is the part that justified the whole design, and I didn't see it coming when I built it.

Landlord admin accounts have full visibility into a landlord's tenants, units and finances. I didn't want a public "forgot password" flow for them at all — a self-serve reset is a public account-recovery surface, which is most of what a registration endpoint is, wearing a different name. So resets are HQLead-only too.

The thing is, once you write that down, "reset this person's password" and "onboard this person for the first time" stop being two features. Both are: issue a fresh temporary credential, issue a single-use token, force a change on next login. Same operation, different trigger.

// landlordadminIdentity.service.js — initiatePasswordReset (HQLead-only)
await landlordadminAuthRepo.update(credentials.id, {
  password_hash: newTempPasswordHash,
  password_change_token: newTokenHash,
  password_change_token_expires_at: in24Hours,
  must_change_password: true,
  failed_attempts: 0,
  locked_until: null,
  refresh_token_hash: null   // refresh token is dead immediately
});

await redis.del(`session:landlord_admin:${id}`);   // and so is the live session

const resetLink = linkService.buildLink("/onboarding/landlord-admin/activate", {
  admin: id,
  token: passwordChangeToken
});

Note the URL: /onboarding/landlord-admin/activate. A password reset sends the user to the activation route, because there is only one route. Nothing new was built for reset — no second token type, no second expiry rule, no second set of edge cases to get wrong. One flow, hardened once.

The counter-example is in the same file. Locking yourself out by mistyping your password three times is not the same operation, so it doesn't reuse this path — unlockAccount clears failed_attempts and locked_until and touches the password not at all. Most lockouts are fat fingers, not compromise, and treating them as compromise would mean destroying a working credential every time someone typos. Reuse where the operation is genuinely identical; a separate path where it isn't.

What the threat model actually becomes

The easy claim here is that no registration endpoint means less attack surface, and that part is true as far as it goes. No spam registrations, no bot signups, no credential stuffing against a public form, no email-verification loop to abuse. That surface isn't defended — it doesn't exist.

But "eliminated" is the wrong word, and it's worth being precise about why. Removing self-service registration doesn't delete the risk. It relocates it, and it concentrates it.

Every account in PropertyOS now exists because HQLead created it. Which means HQLead's own account is the account that can create landlord admins, reset their passwords, and — as of Part 3 — act as them. A public registration form spreads a small amount of risk across thousands of anonymous strangers. This model collapses that risk into a handful of operator accounts, where a single compromise is worth incomparably more.

That's a trade I'd still make, because a small number of high-value accounts is a defensible perimeter and an open registration form isn't. But it only works if you then actually defend them, and it's why the rest of this series spends its time on operator-side controls rather than on signup abuse: per-role JWT secrets and Redis-backed revocable sessions (Part 6), a fully attributable impersonation trail (Part 3), and what happens to all of it under real load (Part 4).

The order matters: the access model came first, and the security priorities are a consequence of it. I didn't pick a list of security features and implement them. I picked who is allowed to exist, and that decided which attacks were worth the effort.

What it cost

Three real costs, in increasing order of how much they bother me.

HQLead is a bottleneck. Nobody onboards themselves. Every new landlord admin and every password reset runs through a human on the inside. For an operator-mediated product that's tolerable. For a self-serve product it would be disqualifying — this design does not generalise, and I wouldn't reach for it again without checking that the business actually works this way.

The temporary password is relayed by hand. There's no email service wired up yet, so createForLandlord and initiatePasswordReset both return the temp password and the activation link straight to HQLead's dashboard, to be passed on by phone or message. It's labelled as such in the UI, but it means a working credential is displayed on a screen and travels over whatever channel the operator happens to pick. That's the weakest link in this entire flow, and it's weak in a way no amount of backend hardening fixes. It's the first thing an email service should close.

The perimeter is small enough to be a single point of failure. Concentrating account creation into HQLead is what makes the model defensible and also what makes it fragile. There's no second operator to notice something wrong, and no approval step between "HQLead decides" and "it happens." Multi-operator review on destructive actions is the obvious next control, and it isn't built yet.

None of those are reasons the decision was wrong. They're the bill that comes with it, and a design writeup that only lists the upside isn't a design writeup.