← Back to writingSecurity / 2026.09

Tenant Admin's Login: From Proof of Concept to Production

For a while, logging into tenant-admin looked like this: type a name, pick a role from a dropdown, done. No password. The cookie it set was plain JSON, unsigned, readable in devtools, and the server trusted whatever role was in it as long as it was spelled correctly.

That wasn't a secret. The permissions document for this app said so directly: the session is a placeholder, not a security boundary yet, good enough for the one person on the one screen who needed it at the time, not for what the tool was about to become. I want to start there rather than with the build itself, because writing that limitation down precisely, in the one document whose whole job is describing who can do what, is what turned "we should get to that eventually" into a scoped, schedulable piece of work.

why it started that way

tenant-admin is the app that publishes what every brand's storefront looks like, theme, layout, which features are on, page content, for however many tenants the platform serves. Early on, the only person who needed to log in was one person on one screen. A name field and a dropdown is a completely reasonable proof of concept for that. It stops being enough the moment more than one person needs access, and the moment any of those people shouldn't be able to touch every brand equally.

Both of those things had already happened by the time a teammate sat down to build the real version. Permissions were global, not scoped to a brand at all. Someone with editor access could open any tenant's config and change it, including one they had no reason to touch. The tool had grown well past what its proof of concept permission model was ever meant to cover, for something that controls what real customers see on real, live sites.

building the real version

The login itself now genuinely authenticates through Google Workspace, the proper OIDC flow, not a shortcut version of it. Authorization requests carry PKCE with the S256 challenge method, a real state parameter, and a real nonce, all generated fresh per attempt and checked on the way back:

const codeVerifier = oidc.randomPKCECodeVerifier(); const codeChallenge = await oidc.calculatePKCECodeChallenge(codeVerifier); const state = oidc.randomState(); const nonce = oidc.randomNonce(); return oidc.buildAuthorizationUrl(config, { redirect_uri: callbackUrl(), scope: "openid email profile", code_challenge: codeChallenge, code_challenge_method: "S256", state, nonce, hd: "travelstart.com", prompt: "select_account", });

The callback rejects anything with an unverified email outright, and the domain restriction isn't left to Google's own hint. The hd parameter above is a hint to Google's login screen, nothing more, so the server independently rechecks the email that just signed in against its own list of allowed domains before it ever creates a session. Two separate places have to agree someone belongs before they're in.

the session stopped being something the browser gets a vote on

The old cookie was, in effect, the client's own state, written by the client, read by the client, trusted by the server. The new one is signed with HMAC, checked with a comparison that runs in constant time, and marked httpOnly, so code running in the browser can't read it or write it at all:

function signature(value: string): Buffer { return createHmac("sha256", getSessionSecret()).update(value).digest(); } export function signPayload<T extends object>( payload: T, maxAgeSeconds: number, now = Date.now(), ): string { const iat = Math.floor(now / 1000); const encoded = Buffer.from( JSON.stringify({ ...payload, iat, exp: iat + maxAgeSeconds }), ).toString("base64url"); return `${encoded}.${signature(encoded).toString("base64url")}`; }

The signing secret has to be at least 32 bytes or the app refuses to start, so there's no path to running with a weak one by accident. The frontend composable that used to read the cookie directly was rewritten to stop doing that entirely. It now just asks the server who it's talking to:

export function useAuth() { const user = useState<AuthUser | null>("auth:user", () => null); async function ensureSession(force = false) { const response = await requestFetch<{ user: AuthUser | null }>("/api/auth/me"); user.value = response.user; } // ... }

If the browser has no way to read or write the session, editing it in devtools stops being a thing you can even attempt.

the harder problem: permissions, not just login

Establishing who someone is was the more straightforward half. Establishing what they're allowed to do, per brand, for the same person, took a second pass right after the first, and it's the more interesting engineering problem of the two.

A user can now hold a default role for the platform as a whole, and a different, more specific role for any individual tenant:

CREATE TABLE user_roles ( email TEXT PRIMARY KEY, default_role TEXT NOT NULL CHECK (default_role IN ('viewer','editor','admin')), sub TEXT, updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE tenant_memberships ( email TEXT NOT NULL, tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, role TEXT NOT NULL CHECK (role IN ('viewer','editor','admin')), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (email, tenant_id) );

Resolving the actual permission for a given request checks the specific override first, then falls back progressively:

export function pickEffectiveRole(options: { membershipRole?: ServerRole | null; storedDefault?: ServerRole | null; mappedRole?: ServerRole | null; }): ServerRole { return options.membershipRole ?? options.storedDefault ?? options.mappedRole ?? "viewer"; }

Every write to a tenant's config now gets checked against that tenant's specific resolved role, not the account's general one. An account that's an admin everywhere else but only a viewer on one particular brand gets a real 403 the moment they try to change that brand's settings, and there's an end to end test that exercises exactly that pairing to prove it, alongside the reverse case, an editor who's specifically an admin on just one tenant successfully changing a feature flag there. This is the actual heart of the production version: not just "can this person log in," but "can this specific person touch this specific brand," checked on the server, on every request, not trusted from anything the client claims.

guarding the thing that manages the guards

Once permissions live in a table instead of a dropdown, the page that edits that table becomes its own small security surface. Changing someone's role, or removing their access, runs through checks that specifically stop you from locking everyone out by accident:

export function assertDefaultRoleChangeAllowed( currentRole: ServerRole, nextRole: ServerRole, adminCount: number, { isSelf }: { isSelf: boolean }, ) { if (isSelf && currentRole === "admin" && nextRole !== "admin") { throw createError({ statusCode: 409, statusMessage: "You cannot change your own app-wide admin role" }); } if (currentRole === "admin" && nextRole !== "admin" && adminCount <= 1) { throw createError({ statusCode: 409, statusMessage: "At least one app-wide admin is required" }); } }

You can't demote yourself out of admin, and you can't remove the last admin standing, the system won't let either action complete. Anything destructive in that panel, a role change, a removal, also requires typing a confirmation string first, not just clicking through a dialog.

Revoking someone's access sets a timestamp on their row and deletes every membership specific to that tenant that they had in the same transaction, and that revoked flag gets checked again on every single request they make afterward, not just at their next login. Whatever time was left on their existing session cookie stops mattering the moment they're revoked, because the permission check that runs behind it fails regardless of what the cookie itself still says.

proving the old shortcut can't come back

The part that stands out most to me isn't the new code, it's a test that deliberately tries the old proof of concept's approach and asserts it fails:

// Pre-signing sessions was how these tests used to authenticate; it must now be rejected. const FORGED_LEGACY_ADMIN_COOKIE = `${AUTH_SESSION_COOKIE}=${encodeURIComponent( JSON.stringify({ name: "E2E Admin", role: "admin" }), )}`; test("a legacy raw-JSON auth-session cookie is rejected with 401", async ({ request }) => { const res = await request.get("/api/tenant-config", { headers: { Cookie: FORGED_LEGACY_ADMIN_COOKIE } }); expect(res.status()).toBe(401); });

That comment states plainly, in writing, exactly what the old proof of concept allowed, right next to a test that fails loudly if anyone ever reintroduces that shortcut. A neighboring test tampers with a single byte of a validly signed session and confirms that gets rejected too. Retiring an old approach once is good. Leaving a named, permanent test aimed at it is the part that means nobody has to remember to check for it again.

the actual lesson

None of this was clever. OIDC, a signed cookie, a permissions table with two columns, none of that is exotic. What made it happen on a real timeline was writing the honest version down first, in a document whose entire purpose was to describe exactly what the proof of concept still was, rather than leaving it as a thing everyone sort of knew and nobody had stated precisely enough to schedule. A vague sense that something should eventually get upgraded stays vague indefinitely. A sentence that says exactly what a proof of concept still allows, and what it doesn't, is a scoped piece of work, and scoped work gets built.

Tech used in this article

  • TypeScript