RCA: AppFolio robot login lockout, Sep 9–11 2026

Why the browser robot that writes into AppFolio locked itself out twice in two days, and what changes so it doesn't happen a third time.

Written 2026-09-11 morning from three read-only investigations (change history, live system evidence, and a walkthrough of how the pieces fit together). All times America/Denver, 12-hour clock. Every number below traces to a database query, a Sentry error record, a Browserbase session log, a Twilio record, a code/deploy record, or a session transcript. Anything marked OPEN is still being run down.

1. What a customer would notice

Since 4:53 PM on Sep 10 (JP&Co and Situs Group) and 3:58 PM on Sep 10 (Western Slope), the robot that writes into AppFolio can't sign in. Every write it attempts gets refused.

No tenant, prospect, or vendor was affected. This was checked directly — a full scan of 6,941 automation-run records since the lockout, logs across five backend jobs, all 3,022 work orders, and all three retry queues (empty). Test-property and stress-test traffic was excluded from every count.

Not affected at all: Clara's answers about unit availability (that comes from a different, read-only feed that ran 488 straight successful pulls at Camellia through this whole window), rent roll data, and renewals data. Nothing needs a manual redo, and nothing is lost.

2. Two separate outages, not one

Outage A — Sep 9, 3:06 PM to about 5:00 PM. Fixed that night.

A real sign-in failure. The robot's saved browser profile got stuck in a redirect loop with AppFolio's login page — 20 attempts in 37 seconds, never even reaching a password box. Chasing it created 21 throwaway browser profiles that day, and every new profile costs a fresh text-message security code: 23 AppFolio codes went to the fede@ number in 53 minutes, and 28 went to the Western Slope number the night before.

Outage B — Sep 10, 3:58 PM onward. Still live.

A different cause: a loop that keeps re-triggering itself without any actual login happening inside it.

3. The mechanism — verified

Confirmed from the code, from Browserbase's own session recordings, and from Sentry's error trail. Roughly every 6 hours, when the safety lock expires, a routine "keep the session alive" check gets one attempt through — and inside a single already-open browser window, in about 8 seconds, this happens:

  1. The keepalive check opens a browser and loads AppFolio's home page. It loads fine — the saved browser cookies are good, the basic access check passes, and a fresh set of session cookies is collected.
  2. The robot checks those cookies against two separate doors: the staff website, and the underlying data API. The staff website says yes. The data API says no (HTTP 401). Verdict: the session is dead. It logs "driving a login refresh."
  3. It calls the login routine — but never actually navigates away from the page it's already on. The login routine's first move is to ask "what's on screen right now?" The answer is the normal dashboard, so it reports back "already logged in" in about 2 seconds. No login form was shown, no password was typed, no security-code text was sent.
  4. The code ignores that "already logged in" answer, re-collects the exact same cookies, and checks them again. Same failure on the data API. It then raises an error, REFRESH_DID_NOT_TAKE, with the message "Drove login successfully but the session it produced is dead (api door 401)." That message is false — nothing was actually driven through a login.
  5. That error lands inside a piece of code (moved there by a change deployed at 11:05 AM Sep 10) that treats it as a bad-credential failure. This is the second such failure counted in 24 hours, which trips a stricter 6-hour lockout. The keepalive check tries again when that lockout expires — and repeats the same failed sequence. Forever, until someone intervenes.

Evidence backing this:

Both logins failed in parallel because they share the same code path, not because they share a password. First trip: Western Slope at 3:58:24 PM, triggered by the keepalive check. JP&Co/Situs Group: 4:53 PM, triggered by a real incoming message about a prospect that needed a guest-card write.

Things we built that made it worse: our own calling code treats REFRESH_DID_NOT_TAKE as worth retrying, so it forces another login attempt. The robot's refusal comes back as an HTTP 503 error, which the message-queue side then redelivers up to four more times. And the keepalive check itself was widened on Sep 9 at 3:53 PM from covering just the JP&Co login to covering all three accounts every 2 minutes — so now three customer accounts sharing two logins are all hammering the same login-budgets constantly.

4. Root cause — AppFolio sessions have a hard 24-hour lifetime

Found. The 24-hour cap itself is inferred at high confidence; everything else below is directly verified.

Signing into AppFolio sets three cookies: one company-wide sign-on cookie good for about 29 days, one "remember this device" cookie good for about 30 days, and one short-lived per-database session cookie. Our keepalive check pings the data API every two minutes, which resets that session cookie's idle clock (about 3 minutes) — but it cannot reset its separate absolute clock. Once that absolute clock runs out, the company-wide sign-on cookie is still perfectly valid, so the staff dashboard still loads and the basic access check still passes — while the data API flatly refuses it. That mismatch is what section 3 calls "hybrid-stale." We proved this exact behavior once before, on 2026-05-25, and it's written into our own troubleshooting notes: a full password-and-code login can restore the data API door.

The timing lines up almost to the minute. The last real logins were Sep 9 at 3:56 PM (clara@ login) and 5:20 PM (fede@ login). The break happened Sep 10 at 3:58 PM — 24 hours and 2 minutes after the clara@ login. All three accounts were separately confirmed working on both doors at 11:13 AM and 11:18 AM on Sep 10, 19 hours in. Our own troubleshooting notes had already clocked the longest a session had ever stayed warm at about 23.3 hours, and had an open question asking whether there's a hard ceiling somewhere past 23 hours. This incident answers that question: yes, 24 hours.

Why this never showed up before, and why all three accounts broke at once: until Sep 8 at 10:11 PM, every write ran a full fresh login and started from a throwaway browser profile each time — so no session ever lived long enough to hit the 24-hour wall. A change that night switched to reusing a saved, already-logged-in browser session instead of logging in fresh every time. The next afternoon, a second change made that saved browser profile persistent and put all three customer accounts on the same indefinitely-reused keepalive. The first 24-hour boundary after both of those changes landed was Sep 10 at about 3:56 PM — which is exactly when it broke.

Why it can't fix itself: the login routine decides a session is fine purely from seeing the normal dashboard on screen — the exact same shortcut that was deliberately removed from a different check back in May for producing false positives. The code calling it throws that "already logged in" answer away, no password is ever actually typed, and that non-attempt then gets counted as if it were a failed login with bad credentials.

Ruled out: duplicate same-name cookies colliding across the different AppFolio sites we log into (the mechanism for that exists in our cookie-handling code, but the same saved session worked fine for 19 hours on the same path first, and a naming collision would fail immediately, not exactly at hour 24); a cookie missing a security flag it needs (same reasoning, and it would point the opposite direction from what we saw on Sep 9); and any code deploy or scheduled job at 3:58 PM (there wasn't one — the last deploy was 4.7 hours earlier and had already passed its own after-the-fact proof check). A previously-logged mistake about "a fresh login isn't the fix" turns out to be about the staff-webpage door, not the data-API door that's actually failing here — there's no prior case of a truly fresh login failing the data-API door.

What we still can't see: there's no way today to just look up the stored session's cookie names, which sites they're scoped to, and when each one expires — the tool that touches them only knows their names well enough to hide them in logs. Before the next real recovery login, we need to capture that information before and after, plus confirm directly that a password was actually typed and that the work-orders endpoint of the API comes back healthy afterward.

5. Why we broke it — what our process missed

Fifteen changes to the browser-robot code shipped in 46 hours (Sep 8 afternoon through Sep 10 lunchtime). Fourteen of them touched login, sessions, or cookies; nine touched the same three files. Almost every individual change fixed a real bug. Stacked on top of each other with no time to let any one of them prove itself, they produced a system that shuts itself off.

6. Timeline (Denver time)

WhenWhat
Sep 8, 2:43 PMWestern Slope credentials added to production
Sep 9, 1:14 PMLogin budget + lockout rule goes live (3/hour, 8/day, 30-min lock then 6-hour lock)
Sep 9, 1:28 PMChange requiring an explicit account on every call goes live; JP&Co fallback removed
Sep 9, 3:06 PMOutage A: lockout trips for fede@ login (30 min)
Sep 9, 3:07 PMA person deliberately extends the lock to 12 hours
Sep 9, 3:53 PMKeepalive check widened to warm all three accounts every 2 minutes; browser profile kept between runs
Sep 9, 4:15 PMStaff-page check added to startup, keepalive, and warm-session logic; login-loop detector added
Sep 9, 4:19 PMEscalation alert observed, 4 minutes after the above change
Sep 9, ~4:50–5:00 PMRestore: browser profile rotated, lockout cleared, one watched login run, real write proven at Willows test property
Sep 9, 5:20 PMLast AppFolio security-code text ever received for fede@
Sep 9, 9:52 PM(Separate system) sync dispatch changed to run per-connection: two workers hitting one AppFolio account; rate-limit errors jump from 30/day to 976/day
Sep 9, 9:53 PMFix goes live: one login attempt now counts as one budget unit, not double-counted
Sep 9, 10:09 / 10:40 PMAll three accounts confirmed warm, no lockouts active
Sep 10, 10:02 AMHealth check: warm, not locked, 6 of 8 daily logins already spent
Sep 10, 11:05 AMChange goes live: every login must now prove itself at both doors; a dead verdict counts as a credential failure; the once-per-flow counting fix from the night before is removed
Sep 10, 11:15 AMNext change goes live (current production version)
Sep 10, 11:18 AMPost-change proof check: all three accounts warm, zero lockouts, fede@ still at 6 of 8
Sep 10, 3:58 PMOutage B begins: Western Slope's saved session fails the data API door; falsely counted as a failed login; lockout trips
Sep 10, 4:00 PMBrowser session rate jumps from ~15/hour to ~135/hour and stays there
Sep 10, 4:32 PMWestern Slope escalates to the 6-hour lockout
Sep 10, 4:53 PMJP&Co session dies mid guest-card message; fede@ lockout trips
Sep 10, 5:24 PMJP&Co/Situs Group escalates to the 6-hour lockout
Sep 10, 11:11 PM(Separate system) fix goes live: one worker per AppFolio account
Sep 11, 1:51 AMTurnover check alert: third red night in a row
Sep 11, 3:30–7:46 AMFour more alerts; automated summaries only, no human action
Sep 11, 10:32 / 11:25 AMNext scheduled lockout expiries — the loop re-triggers again unless the fix lands first

7. What changes — fix chain

A surgical chain of small changes to the robot's codebase, each with its own test that fails before the fix and passes after:

  1. merged Never trust a dashboard landing as proof of login: if the login routine reports "already logged in" while the data-API door is still dead, force one real, full credential login (sign out, then log back in through the actual form) and re-check both doors. (#356, #357)
  2. merged A login attempt that never typed a password is not a credential failure: give it its own error code, LOGIN_NOT_DRIVEN, exempt it from counting against the lockout, and remove the false "Drove login successfully" message. (#356)
  3. merged Re-mint proactively: a nightly keepalive re-mints each account's session 2–5 AM local before the ~24-hour ceiling, kill switch SESSION_REMINT_ENABLED. (#358)
  4. merged Actually record the real login error text in Sentry, and name the failure that tripped the breaker alert. (#359)
  5. merged On our calling side (the main app, not the robot): stop treating REFRESH_DID_NOT_TAKE, the new LOGIN_NOT_DRIVEN, and the 503 refusal as things worth automatically retrying into an active lockout — login-breaker refusals are terminal, no forced refresh, no retry, queue does not redeliver. (#7794)
  6. merged Alerting: one alert per customer organization (Fede's call, 2026-09-11), never one per job or per property. One outage-alarm row per organization with per-job pages suppressed, and one Slack message per organization edited in place — both dark behind PMS_OUTAGE_ALARM. (#7796, #7797)
  7. merged Rotate the browser profile when the login lands in a redirect loop, so a stale profile doesn't strand an account. (#360)
  8. merged A replay gate over eight real login landings, runs on every PR touching the sign-in layer — making it a required check is a branch-protection click Fede owns. (#361)
  9. merged A further PR making the redirect-loop landing not count as a credential failure. (#363)

See section 8 for the full merge record and per-account recovery evidence.

Prevention going forward:

8. Recovery and fixes shipped (2026-09-11)

Robot repo — merged and deployed to production

merged The further PR making the redirect-loop landing not count as a credential failure — #363, production deploy 6397822869.

Main repo — merged

Recovery (Fede's go — "so unlock it")

JP&Co / Camellia — attempt 1 at 9:27 AM failed with ERR_TOO_MANY_REDIRECTS before the login form (stale browser profile, same family as Sep 9); attempt 2 at 9:37 AM after rotating the profile: real login, 89 seconds, 3,057-byte jar, both doors warm at 9:39 AM, still warm at 10:40 AM with no further logins.

Western Slope — same redirect loop on attempt 1, profile rotated, attempt 2 refused because its pause had re-armed; stopped at two attempts; paused until 3:42 PM, expected to self-heal on the next allowed attempt now that #360 is live.

Situs Group — not attempted: it shares JP&Co's login, whose daily allowance reached 8 of 8; expected to self-heal after the window rolls (~3:00 AM Sep 12).

Neither Situs nor Western Slope has live customer traffic.

Two internal caches at Camellia self-heal on their next runs: vendor-contact refresh at 1:30 PM Denver, work-order enrichment at 3:00 AM Denver.

Fede rulings recorded: alerts are grouped per customer organization, never per job or property; no customer-outage page.

PropFlow Docs