Back to Blog

Locking Down Sign-In: Workspace-Only Auth and a Session Lifecycle That Matches Its Provider

By · 10 min read
Security Access Control Authentication TypeScript

An internal ops platform that started as a project tracker had, by this point, grown into the shared front door for eight internal tools — which means its authentication and session handling weren't just protecting a tracker anymore. A single evening security audit found four real gaps behind that front door, and the two weeks after it were spent building something auth code rarely gets: an explicit, logged, correctly-attributed session lifecycle instead of a vague sense that "sessions expire eventually."

A valid token proves who someone is. It doesn't prove they belong in this specific shared table. Those are two different checks, and conflating them is how open data looks like access control.

Restricting sign-in to a verified company domain

PR #94 replaced password sign-in entirely with Google OAuth, scoped to the company's own Google Workspace domain — not "sign in with any Google account," but specifically the organization's verified domain, enforced at four independent layers: the Google consent screen itself is configured Internal, direct Supabase signups are disabled, the OAuth callback explicitly refuses any email outside the domain, and the session gate plus the tool proxy both re-check the domain on every single request rather than trusting the callback's one-time check. Four layers sounds like overkill until you consider what each one is actually independent protection against — a misconfigured consent screen, a stale signup path, a forged callback, and a long-lived session created before the policy existed.

The migration detail worth noting: existing accounts kept their role, tool grants, and editor links intact because Supabase links a new Google identity to the existing user record by matching verified email addresses — the underlying user id never changes, so nothing downstream had to be re-provisioned. PR #97 followed up with the unglamorous polish that makes an auth migration usable rather than merely correct: a clear message instead of a raw invalid_request when someone takes longer than Google's 5-minute OAuth state window to pick an account, an email input for admin-created users that can't be tricked into doubling the company domain, and a sign-in button built to Google's exact branding specification — a new primitive built specifically because the platform's own design-system lint forbids raw unstyled buttons everywhere else, and Google's own brand guidelines forbid restyling their button as anything else.

The audit: four gaps, found and fixed as one coordinated plan

PRs #89 through #93 are explicitly labeled as parts of a single security audit conducted in one evening, and reading them as a set is more illuminating than reading any one in isolation — each finding is a different flavor of the same root mistake: trusting a signal that looked like proof of identity or membership but wasn't.

PR #89 found that a "fast path" serving tool API calls read the caller's role from an x-hub-user-role cookie — HttpOnly stops a browser script from reading that cookie, but says nothing about stopping a person from setting it directly in a request. Any authenticated user with one legitimate tool grant could send a crafted role cookie and reach every tool API call as a superadmin. A second header, the caller's forwarded email, was read out of the unsigned JSON payload sitting next to the one actually-verified field in the auth cookie — so an attacker holding a real token could still rewrite which email every downstream tool believed it was talking to. The fix reads signature-checked claims directly and derives the role from the same database lookup the full authentication path already computed, instead of trusting a second, separately-settable copy of it.

PR #92 is the audit's sharpest finding: because the identity provider allowed open signup with auto-confirmation, anyone holding the public API key could obtain an authenticated session — and nearly every shared table trusted "authenticated" as sufficient proof of belonging. The project tracker could be read, rewritten, and deleted wholesale by that session. A specific account-creation trigger, matched by email string, silently granted one particular address full superadmin access with every tool grant attached — a backdoor that existed by construction, not by exploit. The fix introduces a real membership check — an active profile row, not merely a valid session — and gates every shared table on it. New profiles now start inactive by default, so "I have a session" stops being equivalent to "I am a member of this team."

PR #91 closed the same gap at the admin layer: a form field accepting an arbitrary profile id to link to an editor record, with no check that the id existed, was active, or wasn't already linked elsewhere — meaning an editor's evaluations and work history could be silently redirected onto any account. The same PR fixed granted_by recording a self-promoted user as their own granter, which is exactly the kind of audit-trail bug that only matters the day someone needs to answer "who approved this."

PR #93 is the audit's closing phase, and it's a good model for knowing when a cleanup pass is actually done: it confirms row-level security is live everywhere, every view runs with invoker security rather than inherited privilege, and the few genuinely sensitive database functions aren't callable by low-trust roles — and then revokes TRUNCATE, TRIGGER, and REFERENCES privileges that the database platform grants to anonymous and authenticated roles by default. None of those three are reachable through the application's actual API surface, which is precisely why the PR frames the fix as hygiene rather than a live exploit — closing a door nothing was using yet is still worth doing before something learns to.

A session cookie that doesn't comma-fold, and other quiet correctness bugs

Several of the fixes in this cluster aren't access-control gaps so much as the kind of subtle correctness bug that security hardening tends to surface as a side effect of reading code more carefully than usual.

PR #88 found that the function responsible for ending a disabled user's sessions had been calling the wrong overload of the identity provider's sign-out method for its entire existence — the first argument that API expects is the user's own JWT, not their id, so every single invocation failed with an invalid-token error that was logged and silently swallowed. Disabling, demoting, or deleting an account through the admin panel had never actually ended anyone's session. The bug was caught the ordinary way real security bugs get caught in a working system: while closing a specific person's access after an email change, and it stayed caught because the fix shipped with a new test that fails against the old code and passes against the new — proof the original code path never worked, not just an assertion that the new one does.

PR #90 is the single largest fix in the cluster, closing five separate session-correctness gaps the audit's other PRs had exposed: the one-hour idle timeout had never actually fired, because the activity-tracking cookie expired at exactly the idle window and an idle session simply arrived with no stamp at all — which the code read as "first contact" rather than "this session has been idle." Disabled accounts kept access to every page except the ones that explicitly checked an active-status flag. Sign-out and access revocation never propagated to the tool layer at all, so a revoked token kept working on tool API calls until its underlying JWT naturally expired up to twelve hours later. An open-redirect vector let a crafted path slip past validation by exploiting how browsers collapse a tab character into what the URL parser then reads as a protocol-relative double-slash. And the session cookie itself was readable by any script on the page, rather than marked HttpOnly — which matters precisely because this platform's tools share its origin, making that cookie reachable from any tool-side cross-site-scripting bug, not just a bug in the hub itself.

Choosing a session lifetime nobody guessed at

The idle and absolute session timeouts went through three revisions in two weeks, and the sequence is a better story than any single number would be. The first policy — one hour idle, twelve hours absolute — sounded reasonable until it meant every editor was being signed out once a day regardless of when they'd started working, since twelve hours from a 9am sign-in lapses mid-afternoon rather than at a natural break point. PR #87 widened the absolute limit to thirty-six hours specifically so a single morning sign-in survives through the end of the following working day — a deviation from a published security guideline's recommended twenty-four-hour ceiling, documented explicitly as a deliberate, justified exception rather than an unexamined default.

That still wasn't the end of it. Production logs showed editors being signed out mid-session for reasons the hub's own policy couldn't explain — PR #100 traced it to the identity provider's own separate inactivity timeout, which was returning a specific "session expired (inactivity)" error hundreds of times over three days while its own dashboard reported the setting as disabled. PR #102 found the actual root cause: an earlier attempt to configure that timeout had been applied as a hot-reload the provider's own config-loading code can't actually unset once a value is set, and the dashboard reporting "0" meant "unchanged from whatever's still running," not "off." The real fix was setting the provider's own inactivity timeout to a value functionally equivalent to "never" and documenting, in detail, why that field should never be set to zero expecting it to mean disabled. PR #96 then landed the actual final numbers — twelve hours idle, forty-eight hours absolute — chosen not arbitrarily but specifically to match the identity provider's own session limits, so the platform's policy and the provider's policy tell the same story instead of two different, silently conflicting ones.

RevisionIdleAbsoluteWhy it changed
Original1h12hSigned everyone out daily, at an arbitrary time
PR #871h36hOne sign-in should survive to the end of the next working day
PR #96 (final)12h48hMatched to the identity provider's own actual limits

Four reasons a session ends, and none of them were being logged

PR #103 is a small PR carrying a large point: a session on this platform can end for four genuinely distinct reasons — idle timeout, absolute timeout, a domain check failing, or an account being disabled — and until this PR, none of the four were logged. One morning's cluster of idle sign-outs had to be reconstructed after the fact from the identity provider's own auth logs, because the application itself had no record of what it had done or why. The fix is purely additive logging with no behavior change, which is exactly the right scope for this kind of gap — you don't need a new mechanism to end sessions correctly if the four mechanisms already exist and just aren't visible. PR #101 fixed a sharp edge in that same logging: the new log line was reading the session owner's id from a cookie jar after the very refresh failure that deletes that cookie, so every production log line it had produced so far recorded no user at all. Reading the id first, before the operation that invalidates it, fixed a bug in the fix.

The small bugs a real deploy cycle finds

PR #98 fixed sign-out breaking for anyone who had a tab open across a deploy — the sign-out action was implemented as a framework server action, and a server action's identifier changes with every build, so a stale tab's sign-out request simply 404'd. The fix routes sign-out through a stable URL instead, hardened to accept only same-origin POST requests so a stray link or prefetch can't trigger it. PR #99 preserved the query string through the sign-in redirect, so a deep link into a specific filtered view survived a forced re-authentication instead of dumping the user back at a bare top-level page. PR #104 closed out two silent failure paths found in a late-night log review: a proxied request that failed at the connection level with no logging at all, fixed with one same-method retry for safe, idempotent requests only, and a cold-start race where concurrent first requests after a deploy each tried to fetch the same cryptographic key set independently, producing a false key-mismatch error that a shared, de-duplicated fetch eliminates entirely.

The pattern: a session isn't one event, it's a lifecycle with four exits

Every fix in this cluster traces back to the same realization stated different ways: "the user is signed out" is not one event. It's at least four distinct events — ran out of activity, ran out of absolute time, left the company's identity boundary, or had access explicitly revoked — and each one needs its own correct detection, its own correct cookie cleanup, its own propagation to every layer that enforces access, and its own log line explaining which of the four just happened. Treating session termination as a single undifferentiated "logged out" state is exactly how a disabled account keeps working for twelve more hours, how an idle timeout silently never fires, and how a genuine security incident becomes a mystery nobody can reconstruct from the one bit of information the system bothered to record.

Related Articles