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.