Engineering Journal
Ginexys
Ginexys

Browser Auth Without a Backend: The GINEXYS Architecture

2026-06-03

TLDR: GINEXYS does auth without a traditional backend. Here is the exact architecture: three surfaces, one session model, Cloudflare Functions as the edge layer, and a VS Code extension handshake that encrypts tokens in transit using AES-GCM derived from a per-session HMAC key.

The Constraint

No persistent server. Auth runs on Cloudflare Pages Functions — which are stateless edge workers — plus Supabase for identity. This eliminates server-side session stores. Every session decision has to be either stateless (JWT verification) or delegated to an external store (Supabase, KV).

Three Auth Surfaces

Surface 1: Browser modal (ginexys-auth.js)

The primary sign-in path is a self-contained modal loaded on any page. It does two things:

  1. Email/password sign-in via sb.auth.signInWithPassword().
  2. OAuth via sb.auth.signInWithOAuth() with skipBrowserRedirect: true — the OAuth URL is opened in a new tab, not a redirect. This preserves the current tool state.
After sign-in, the modal calls POST /api/auth/session { action: 'set', access_token, expires_in } to write an HttpOnly cookie. It then removes the Supabase localStorage keys — those were only the handoff channel.
// After OAuth completes in the callback tab:
async function _handleOAuthReturn() {
  if (!_oauthPending) return;
  _oauthPending = false;
  var token = _getStoredToken(); // reads Supabase localStorage
  if (!token) return;
  // Evict from localStorage immediately — cookie is the real session
  for (var i = localStorage.length - 1; i >= 0; i--) {
    var k = localStorage.key(i);
    if (k && k.startsWith('sb-') && k.endsWith('-auth-token')) localStorage.removeItem(k);
  }
  await _onSuccess(token, null);
}

Surface 2: Standalone login page (auth/login.html)

Used for direct navigation and VS Code handshake. Stores tokens in sessionStorage only (not localStorage) and strips the refresh_token before storing:

const _secureStorage = {
  setItem: (k, v) => {
    try {
      const parsed = JSON.parse(v);
      if (parsed?.refresh_token) delete parsed.refresh_token;
      sessionStorage.setItem(k, JSON.stringify(parsed));
    } catch {
      sessionStorage.setItem(k, v);
    }
  },
  // ...
};

No refresh_token in storage means the session is tab-scoped. When the tab closes, the token is gone. The HttpOnly cookie is the durable session. This is intentional: a stolen access_token expires in one hour; a stolen refresh_token never expires.

Surface 3: VS Code extension (AuthProvider.ts)

The extension cannot open a browser redirect (it would lose state). Instead it uses a polling handshake:

  1. Extension generates state (UUID) and secret (32 random bytes, hex-encoded).
  2. Extension POSTs to ginexys.com/auth/vscode with Authorization: Bearer <secret>. This creates a row in vscode_auth_sessions with the state and secret, expiring in 5 minutes.
  3. Extension opens ginexys.com/auth/login.html?state=<state>&from=vscode in the browser.
  4. User signs in. callback.html encrypts the access token with AES-GCM and writes it to the session row.
  5. Extension polls ginexys.com/auth/vscode-poll?state=<state> with Authorization: Bearer <secret> every 5 seconds.
  6. On delivery, the edge function decrypts the token, returns it, and deletes the row.
The encryption key is derived per-session: HMAC-SHA256(SERVICE_ROLE_KEY, state). The derived key is never stored. The state is the key derivation input, so the row cannot be decrypted without knowing both the service role key (server-only) and the state (URL-public, not secret). The token in storage is always encrypted at rest.
// AuthProvider.ts — token stored in OS keychain, not filesystem
class TokenStore {
  private readonly SECRET_KEY = 'ginexys-extension-token';

async save(token: string): Promise<void> { await this.context.secrets.store(this.SECRET_KEY, token); } }

context.secrets maps to the OS keychain per platform: macOS Keychain, Windows Credential Manager, Linux SecretService.

The Session Model

All three surfaces converge on the same HttpOnly cookie gx_session. JavaScript cannot read it. /api/me reads it as a fallback when no Authorization header is present.

The cookie is set by /api/auth/session:

const SECURE_FLAGS = 'HttpOnly; Secure; SameSite=Lax; Path=/';
const maxAge = Math.min(parseInt(expires_in) || 3600, 3600); // cap at 1 hour
return new Response(JSON.stringify({ ok: true }), {
  headers: {
    'Set-Cookie': gx_session=${token}; ${SECURE_FLAGS}; Max-Age=${maxAge},
    'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
  },
});

On logout, the same endpoint is called with action: 'clear', which sets Max-Age=0 to immediately expire the cookie. This is the server-side session invalidation — without it, logout is cosmetic.

Cross-Tab Sync

When OAuth completes in a popup tab, the parent tab needs to know. Two mechanisms run in parallel:

Primary — BroadcastChannel:

// callback.html (the popup) const bc = new BroadcastChannel('gx:auth'); bc.postMessage({ type: 'gx:auth-complete' }); bc.close();

Fallback — visibilitychange:

document.addEventListener('visibilitychange', () => {   if (document.visibilityState === 'visible') _handleOAuthReturn(); });

BroadcastChannel fires immediately regardless of focus. visibilitychange only fires when the tab regains focus — it handles the case where the popup closes before broadcasting (race condition on slow connections).

The PKCE Invariant

Every Supabase client init includes flowType: 'pkce'. This makes Supabase use the PKCE flow (RFC 7636) for all OAuth. The authorization server verifies the code_challenge/code_verifier pair before issuing tokens. An intercepted authorization code is worthless without the verifier, which never leaves the browser.

What This Cannot Do

No server-side refresh. Tokens expire after 1 hour and the user re-authenticates. Acceptable for a developer tool — unacceptable for a consumer product with background sync. The correct fix is a refresh endpoint that reads the HttpOnly cookie and issues a new access token, but that requires storing the refresh token server-side (not in the browser), which requires a persistent store.

That is the tradeoff: pure-edge auth is simpler to operate but weaker on session longevity. For a tools platform where sessions are short and intentional, it is the right call.

Read this post in the full Engineering Journal →