Engineering Journal
Ginexys
Ginexys

Auth Hardening Postmortem: What the Checklist Found

2026-06-03

TLDR: A structured auth audit against 27 requirements found 7 real issues in production code. None were catastrophic, but two were in the Critical/High category. All are fixed. Here is what went wrong and why.

What We Audited

GINEXYS has three auth surfaces: a browser modal (ginexys-auth.js), a standalone login page (auth/login.html), and a VS Code extension flow that does a browser-based OAuth handshake. The auth session is managed by a Cloudflare Pages Function that sets an HttpOnly cookie.

We used the GINEXYS auth security checklist, which covers 27 requirements derived from RFC 6749, RFC 7636, OWASP ASVS Level 2, and NIST 800-63B.

What We Got Right

Most of it. PKCE was enabled on every Supabase client init. The HttpOnly cookie pattern was implemented correctly. The VS Code token is stored in context.secrets (maps to OS keychain per platform). BroadcastChannel is used for cross-tab auth sync, with visibilitychange as a fallback. The OAuth flows force account selection with prompt: select_account (Google) and prompt: login (GitHub) on all primary surfaces.

What the Checklist Found

Finding 1: poller_secret in a URL query parameter.

The backup proxy at /api/auth/vscode-poll.js was passing the polling secret as ?secret=Y in the URL. The primary proxy at /auth/vscode-poll.js forwarded it via Authorization: Bearer header correctly. The secondary file was copied from an earlier draft and never updated. URL params end up in server access logs, CDN logs, and browser history. The secret was live in every poll request for the duration of a VS Code sign-in session.

Fix: remove searchParams.get('secret'), read from Authorization header instead. Same fix pattern as the primary proxy.

Finding 2: Logout did not clear the server-side cookie.

_signOut() in os-shell.js called _sb.auth.signOut() and deleted Supabase keys from localStorage. It did not call POST /api/auth/session { action: 'clear' }. The HttpOnly gx_session cookie persisted after logout until the JWT expired (up to 1 hour). A stolen device or shared browser session would remain authenticated for up to an hour after the user clicked Sign Out.

Fix: add the fetch('/api/auth/session', { body: { action: 'clear' } }) call at the start of _signOut().

Finding 3: postMessage sent with '*' wildcard target on 9 call sites.

ginexys-gate.js, the tool HTMLs, bridge.js, analyzePanel.js, and kernel.js (as a fallback) all used window.parent.postMessage(payload, ''). None of the payloads contained credentials. But '' means the message can be received by any origin — if the page is ever embedded in a cross-origin iframe, an attacker-controlled parent can receive these signals. The checklist requires a known origin on all postMessage calls.

Fix: replace '*' with window.location.origin on all 9 sites. The tools and shell are always same-origin in production.

Finding 4: /api/auth/session had no rate limiting.

The session endpoint accepts any access token and sets a cookie. Without rate limiting, an attacker could hammer the endpoint to probe for valid tokens or to exhaust KV write budget. The endpoint had no per-IP protection.

Fix: add a lightweight KV-backed counter (10 requests per IP per minute, 60-second TTL) using the already-bound VISITOR_KV namespace.

Finding 5: HSTS header absent from auth endpoint responses.

The auth/login.html and auth/callback.html pages carry a CSP with frame-ancestors 'none'. But the Content-Security-Policy meta tag only applies in the browser — it does not serve as an HTTP response header from Cloudflare Functions. The /api/auth/session function responses had no Strict-Transport-Security header.

Fix: add Strict-Transport-Security: max-age=31536000; includeSubDomains to every response from /api/auth/session.

Finding 6: Account enumeration via raw Supabase error messages.

Both auth/login.html and ginexys-auth.js rendered error.message from Supabase directly. Supabase returns different messages for "wrong password" versus "email not confirmed" versus "user not found." An attacker probing the sign-in form could distinguish registered from unregistered emails.

Fix: normalize all sign-in errors to "Invalid email or password." before displaying.

Finding 7: prompt parameter missing on OAuth in ginexys-shell.js and ginexys-shell.ts.

The shared web component versions of the auth modal (ginexys-shell.js and its TypeScript source) called signInWithOAuth without queryParams: { prompt: ... }. The primary modal (ginexys-auth.js) and the standalone login page (auth/login.html) both had it. The web component path was a gap.

Fix: add queryParams: provider === 'google' ? { prompt: 'select_account' } : { prompt: 'login' } to both files.

What the Plan Got Wrong

We assumed that because the primary code paths were hardened, the secondary/backup paths were equivalent. They were not. The /api/auth/vscode-poll.js backup proxy and the ginexys-shell.js web component were both older implementations that predated the hardening pass.

The lesson: hardening one implementation of a pattern does not harden all implementations of that pattern. The audit needs to scan for every call site, not just the primary one.

What Survived

The PKCE flow, the HttpOnly cookie pattern, the BroadcastChannel design, the OS keychain storage in the extension, the AES-GCM token encryption for the VS Code handshake, the redirect URI validation, and the rate limiting on session creation — all held up.

Final State

27 requirements audited. 19 pass. 7 fixed. 1 N/A (4.5 — single-use authorization codes: delegated to Supabase; no custom code exchange logic). Pass rate: 26/26 applicable (100%).

Read this post in the full Engineering Journal →