Back to engineering

Engineering

An idle timer you cannot reset by deleting a cookie

The Bookatu engineering team8 min read

A business can now sign its team out after 15 minutes to 2 hours without activity. Our sessions are stateless, so the server has no record of when anyone last did anything. Here is how a signed, per-business activity cookie answers that question without being something you can delete for a fresh window, and the two review findings that changed how it ships.

The short version

  • Auto sign-out is a per-business rule: 15, 30, 60 or 120 minutes, or off. Every admin page and action checks it in requireOrgAccess, the guard they already call.
  • Sessions are stateless signed tokens, so activity is carried in a second signed cookie, one per business, bound to the login, the business and the sign-in (the session's iat).
  • With no valid cookie, the clock starts at the later of the sign-in and the moment the rule was set. Deleting the cookie gives you the strictest answer, not a fresh window.
  • Only the browser's own report of a click, key, scroll or touch writes the cookie. Polling, refreshes and prefetches read it and never move it.
  • Review finding one: the report was first a server action, and setting a cookie inside a server action made Next.js re-render the whole page every minute. It is now a route handler, and two tests pin that.
  • Review finding two: approving an OAuth app hands it owner-level access, so the consent action now holds to the idle rule too.

Front desks share computers. Someone signs in to the admin at nine, gets pulled away to a client, and the screen stays open on the finance page for the rest of the day. The fix people expect is the one their bank uses: if nobody has touched it for a while, sign it out.

On most stacks this is a column on the session row, updated on each request. Our sessions have no row. A session is a signed token in a cookie, checked by signature and a per-login revocation epoch. That is cheap and simple, and it means the server has no idea when anyone last did anything.

A second signed cookie

We did not want to add a session table and a write on every request for a rule most businesses leave off. Instead, while a business has the rule on, the browser reports activity at most once a minute, and the server stamps the time into a signed cookie that belongs to that business:

ts
export function idleActivityCookieName(orgId: string): string {
  return `bk_idle_${crypto.createHash("sha256").update(`bookatu:idle:${orgId}`).digest("base64url").slice(0, 16)}`;
}

function key(master: string): Buffer {
  return Buffer.from(crypto.hkdfSync("sha256", master, Buffer.alloc(0), "bookatu:idle-activity:v1", 32));
}

One cookie per business, so one business's rule never reads or writes another's. A person on two teams can be signed out of one and still working in the other. The name is a hash, so the business id is not spelled out on every request. The HMAC key is derived from the master secret with its own HKDF label, so a session token can never pass as an activity cookie or the other way round. A unit test signs an activity payload with the session cookie's key and checks that it is refused.

The cookie carries five short fields: the login, the business, the sign-in it belongs to (the session token's iat), the last activity time, and a flag set once the person's own timer has signed them out.

Whose cookie is it

A valid signature is not enough. The cookie has to be this login's, at this business, from this sign-in:

ts
function belongs(a: IdleActivity | null, userId: string, orgId: string, sessionIat: number): a is IdleActivity {
  return !!a && a.u === userId && a.o === orgId && a.s === sessionIat;
}

Binding it to the sign-in matters in both directions. Signing in again is a new session with a new iat, so an old stamp stops counting against you. And a stamp from yesterday's session, kept in a browser that someone copied cookies out of, cannot keep today's session alive.

Deleting the cookie never buys time

This is the decision in the title. The natural reading of "no cookie" is "just started", which fits the first page after sign-in. It would also let anyone reset the timer by clearing one cookie, which is exactly what an unattended browser does not need. The verdict runs from the stamp when there is a valid one, and otherwise from the later of the sign-in and the moment the rule was last set:

ts
export function idleVerdict(args: {
  setting: IdleSignOutSetting;
  userId: string;
  orgId: string;
  sessionIat: number;
  activity: IdleActivity | null;
  now: number;
}): IdleVerdict {
  const { setting, userId, orgId, sessionIat, activity, now } = args;
  const mine = belongs(activity, userId, orgId, sessionIat) ? activity : null;
  if (mine?.x) return { idle: true, lastActiveAt: Math.min(mine.t, now), remainingMs: 0 };
  const from = Math.min(now, Math.max(mine ? mine.t : sessionIat, setting.since));
  const deadline = from + setting.minutes * 60_000;
  return { idle: now > deadline, lastActiveAt: from, remainingMs: Math.max(0, deadline - now) };
}

With no cookie, the clock starts at the sign-in. A session signed in three hours ago, under a rule that was already on, is three hours idle without its cookie. Deleting the cookie can only make your answer stricter.

setting.since is the other half. When an owner turns the rule on, or changes the window, the time is saved with it. Nobody's idle time counts from before that, so turning the rule on never signs out a colleague who was working a minute ago, the owner included. The outer Math.min(now, ...) means a time from the future, in a hand-edited setting or a stamp, can never push the deadline out.

The integration test for the title is short:

ts
it("deleting the activity cookie does not buy a fresh window", async () => {
  await turnOn(A, 15);
  signIn(a.users.staff);
  clock = T0 + 10 * MIN;
  await touchIdleActivityAction(A, 0);
  clock = T0 + 26 * MIN;
  h.jar.delete(idleActivityCookieName(a.orgId));
  expect((await inA()).to).toBe(idleSignedOutPath(A));
});

What counts as activity

requireOrgAccess only reads the cookie. It is written in exactly two places: the activity report, and the timer's own sign-out, which sets the flag. Our admin polls for some things, refreshes others, and Next.js prefetches links. None of those move the stamp, so a dashboard left open with a live clock on it still goes idle.

The browser side listens for pointerdown, keydown, wheel, touchstart, mousemove and scroll, and reports at most once a minute, trailing, so the stamp is never more than a minute behind the last real input. The server does not take the browser's word for when that was. The report says how long ago the activity happened, and the server clamps it:

ts
const back = Number.isFinite(args.idleMs) ? Math.min(IDLE_PING_EVERY_MS, Math.max(0, args.idleMs)) : 0;
const mine = belongs(existing, userId, orgId, sessionIat) ? existing : null;
const t = Math.max(now - back, mine ? Math.min(mine.t, now) : 0);
return { u: userId, o: orgId, s: sessionIat, t };

A browser can claim activity at most a minute back and never in the future, and the stamp never moves backwards. The report itself goes through requireOrgAccess without skipping the idle check. A browser that is already idle past the window is refused before it can write anything, so a late report cannot bring a session back.

A minute before the end, the browser first asks the server how long is really left, since another tab may have kept the session alive, and asking does not count as activity. Then it shows a warning with a countdown. A stray mouse move does not dismiss the warning. Stay signed in does.

The guard

The check sits in requireOrgAccess, after the membership check and before the role, email and two-step gates:

ts
if (!opts.skipIdleGate && (await idleSignOutState(org, user))?.idle) {
  redirect(idleSignedOutPath(org.slug));
}

idleSignOutState reads the org row the guard already holds and two cookies, so a business with the rule off pays no query at all. Only the timer's own two actions pass skipIdleGate: asking how long is left, and the sign-out itself, because both have to be able to answer "no time left". Platform support staff are not held to one business's rule, and the phone app's bearer path returns before this check and is left as it was.

Review finding one: every report reloaded the page

The first version reported activity through a server action, like the timer's other two calls. It worked, and its tests passed. The review found what it was doing to the page around it.

That came from how Next.js treats cookies set inside a server action. Setting one makes the response report the action as revalidating. The client then empties its prefetch cache and re-renders the whole current page: the admin layout, the calendar, finance and all their queries. The report sets a cookie up to once a minute while someone works, so the admin was quietly reloading itself under them every minute.

The fix was to move the one call that writes the cookie to a route handler, POST /api/idle-activity. A route handler sets the cookie with no effect on the page. It keeps the same guard. A refusal comes back as a 401 with the redirect target, which the browser opens, the same as the action's redirect did. It only accepts a JSON body, so a form on another site cannot post to it. The comment at the top of the route explains why it is not an action, so nobody tidies it back into one:

ts
* A route handler, not a server action, on purpose: any cookie set inside a
* server action makes Next report the action as revalidating, and the client
* then empties its prefetch cache and re-renders the whole current page (the
* admin layout and the calendar, finance and so on, with all their queries).

Two tests pin it. The integration test checks that the actions module no longer exports a report action at all, and that the route answers with no-store and writes the cookie. The component test drives the timer through two minutes of activity and checks the reports went through the plain request:

ts
expect(client.reportIdleActivity).toHaveBeenCalledTimes(2);
expect(Object.keys(actions).sort()).toEqual(["idleSignOutAction", "idleStatusAction"]);
expect(actions.idleStatusAction).not.toHaveBeenCalled();
expect(actions.idleSignOutAction).not.toHaveBeenCalled();

The same trap caught the passkey sign-in page in the same release, where asking for a challenge sets a cookie and the page asks for one as soon as it opens. That fix was the same: a route handler.

Review finding two: the OAuth consent page

Bookatu lets owners and admins connect outside apps through OAuth. The consent action is not under /admin and did not call requireOrgAccess. It checked the session, two-step login and that the user was an owner or admin of the chosen business. A browser signed out of a business for inactivity could still open the consent page and hand an app owner-level access to that business.

The fix holds the consent action to the same rule as every admin action:

ts
const org = orgId ? await getOrgById(orgId) : null;
if (org && (await idleSignOutState(org, user))?.idle) redirect(idleSignedOutPath(org.slug));

The tests cover a browser idle past the window, a browser whose own timer signed it out two minutes in, and the two cases that must keep working: the same owner while active, and the same owner at another business that has the rule off. The idle browser gets no authorization code. The active owner, and the same owner at the other business, each get one.

A rule that guards the admin has to guard every door that grants what the admin grants.

What we take from it

  • When the server keeps no state, decide what a missing cookie means before anything else. Here it means the strictest answer, so deleting it gains nothing.
  • Bind a client-held stamp to everything it is about: the login, the business and the sign-in. A valid signature is not ownership.
  • Only an explicit report should count as activity. Anything the app does on its own, such as polling, refreshing or prefetching, must not keep a session alive.
  • In Next.js, a cookie set inside a server action re-renders the page. A call that sets a cookie often belongs in a route handler.
  • List every door that grants the same access, not just the ones under the same path. OAuth consent was one we nearly missed.
securitysessionsnext.jscookiesengineering

Building on Bookatu?

Bookatu has a public REST API and webhooks. Have a look at the developer docs.

Developer docs