diff --git a/.secrets.baseline b/.secrets.baseline
index 6bdd5380a..269bd1e9a 100644
--- a/.secrets.baseline
+++ b/.secrets.baseline
@@ -2275,7 +2275,7 @@
"filename": "frontend/src/lib/auth-context.tsx",
"hashed_secret": "77821f02d353c282dc3e4ea86a845a3fb8a9aa44",
"is_verified": false,
- "line_number": 63
+ "line_number": 64
}
],
"frontend/src/lib/gs1-parser.test.ts": [
@@ -3738,5 +3738,5 @@
}
]
},
- "generated_at": "2026-10-25T07:05:37Z"
+ "generated_at": "2026-10-26T14:49:21Z"
}
diff --git a/frontend/src/__tests__/auth/client-bypass.test.tsx b/frontend/src/__tests__/auth/client-bypass.test.tsx
index 341989d74..ab5f7331e 100644
--- a/frontend/src/__tests__/auth/client-bypass.test.tsx
+++ b/frontend/src/__tests__/auth/client-bypass.test.tsx
@@ -108,15 +108,18 @@ describe('client-side auth bypass attempts (adversarial, must hold)', () => {
expect(result.current.user).toBeNull();
});
- it('XSS-shaped email text seeded client-side stays inert data, not markup', async () => {
+ it('XSS-shaped email text seeded client-side is never persisted', async () => {
const { seedPasswordlessClientUser } = await import('@/lib/passwordless-auth');
- seedPasswordlessClientUser('
@example.com');
+ const xssEmail = '
@example.com';
+ seedPasswordlessClientUser(xssEmail);
const raw = localStorageMock.getItem('regengine_user');
- expect(raw).toContain('
');
+ expect(raw).not.toBeNull();
+ expect(raw as string).not.toContain('
');
+ expect(raw as string).not.toContain(xssEmail);
const parsed = JSON.parse(raw as string) as {
email: string; is_sysadmin: boolean; permissions: string[];
};
- expect(typeof parsed.email).toBe('string');
+ expect(parsed.email).toBe('');
expect(parsed.is_sysadmin).toBe(false);
expect(parsed.permissions).toEqual([]);
});
diff --git a/frontend/src/__tests__/hooks/useAuth.test.tsx b/frontend/src/__tests__/hooks/useAuth.test.tsx
index 52a8ee57f..f6ab0c6dc 100644
--- a/frontend/src/__tests__/hooks/useAuth.test.tsx
+++ b/frontend/src/__tests__/hooks/useAuth.test.tsx
@@ -66,6 +66,7 @@ describe('useAuth Hook', () => {
beforeEach(() => {
vi.clearAllMocks();
localStorageMock.clear();
+ window.sessionStorage.clear();
(isSupabaseConfigured as any).mockReturnValue(false);
(createSupabaseBrowserClient as any).mockImplementation(() => {
throw new Error('Supabase not configured');
@@ -102,10 +103,10 @@ describe('useAuth Hook', () => {
expect(result.current.accessToken).toBeNull();
});
- it('hydrates from localStorage on mount', async () => {
+ it('hydrates authenticated email in memory without retaining it in storage', async () => {
const mockUser = {
id: '123',
- email: 'test@example.com',
+ email: `${crypto.randomUUID()}@example.test`,
name: 'Test User',
is_sysadmin: false,
tenant_id: 'tenant-123',
@@ -145,6 +146,10 @@ describe('useAuth Hook', () => {
});
expect(result.current.user).toEqual(mockUser);
+ expect(apiClient.setUser).toHaveBeenLastCalledWith(mockUser);
+ expect(JSON.parse(localStorageMock.getItem('regengine_user')!)).toEqual({ ...mockUser, email: '' });
+ expect(localStorageMock.getItem('regengine_user')).not.toContain(mockUser.email);
+ expect(window.sessionStorage.getItem('regengine_user')).toBeNull();
// Access token is now a placeholder since actual token is in HTTP-only cookie
expect(result.current.accessToken).toBe('cookie-managed');
});
@@ -184,10 +189,10 @@ describe('useAuth Hook', () => {
expect(result.current.accessToken).toBe('cookie-managed');
});
- it('persists user to localStorage on login', async () => {
+ it('keeps login email in memory and out of browser storage', async () => {
const mockUser = {
id: '123',
- email: 'test@example.com',
+ email: `${crypto.randomUUID()}@example.test`,
name: 'Test User',
is_sysadmin: false,
tenant_id: 'tenant-123',
@@ -204,8 +209,11 @@ describe('useAuth Hook', () => {
await result.current.login('test-token', mockUser, 'tenant-123');
});
- // User profile is still stored in localStorage (non-sensitive)
- expect(localStorageMock.getItem('regengine_user')).toBe(JSON.stringify(mockUser));
+ expect(result.current.user).toEqual(mockUser);
+ expect(apiClient.setUser).toHaveBeenLastCalledWith(mockUser);
+ expect(JSON.parse(localStorageMock.getItem('regengine_user')!)).toEqual({ ...mockUser, email: '' });
+ expect(localStorageMock.getItem('regengine_user')).not.toContain(mockUser.email);
+ expect(window.sessionStorage.getItem('regengine_user')).toBeNull();
// Tenant ID is still stored in localStorage (non-sensitive)
expect(localStorageMock.getItem('regengine_tenant_id')).toBe('tenant-123');
});
@@ -399,7 +407,7 @@ describe('useAuth Hook', () => {
it('hydrates Supabase fallback sessions from /api/session instead of local browser auth state', async () => {
const storedUser = {
id: 'stored-user',
- email: 'stored@example.com',
+ email: `${crypto.randomUUID()}@example.test`,
is_sysadmin: false,
status: 'active',
role_name: 'owner',
@@ -407,7 +415,7 @@ describe('useAuth Hook', () => {
};
const supabaseUser = {
id: 'supabase-user',
- email: 'stored@example.com',
+ email: `${crypto.randomUUID()}@example.test`,
tenant_id: 'tenant-123',
role_name: 'member',
is_sysadmin: false,
@@ -442,7 +450,7 @@ describe('useAuth Hook', () => {
});
expect(result.current.user?.permissions).toEqual(['inflow.write', 'reports.read']);
- expect(localStorageMock.getItem('regengine_user')).toBe(JSON.stringify(supabaseUser));
+ expect(localStorageMock.getItem('regengine_user')).toBe(JSON.stringify({ ...supabaseUser, email: '' }));
expect(createSupabaseBrowserClient).not.toHaveBeenCalled();
});
@@ -808,7 +816,7 @@ describe('useAuth Hook', () => {
describe('Stale session cleanup (hydration)', () => {
const staleUser = {
id: 'stale-user',
- email: 'stale@example.com',
+ email: `${crypto.randomUUID()}@example.test`,
is_sysadmin: false,
status: 'active',
role_name: 'member',
@@ -882,7 +890,10 @@ describe('useAuth Hook', () => {
});
expect(result.current.user?.id).toBe('stale-user');
- expect(localStorageMock.getItem('regengine_user')).toBe(JSON.stringify(staleUser));
+ expect(result.current.user?.email).toBe(staleUser.email);
+ expect(apiClient.setUser).toHaveBeenLastCalledWith(expect.objectContaining({ email: staleUser.email }));
+ expect(localStorageMock.getItem('regengine_user')).toBe(JSON.stringify({ ...staleUser, email: '' }));
+ expect(window.sessionStorage.getItem('regengine_user')).toBeNull();
});
it('keeps the hydrated user when the session check returns a non-2xx response', async () => {
@@ -903,7 +914,41 @@ describe('useAuth Hook', () => {
});
expect(result.current.user?.id).toBe('stale-user');
- expect(localStorageMock.getItem('regengine_user')).toBe(JSON.stringify(staleUser));
+ expect(result.current.user?.email).toBe(staleUser.email);
+ expect(apiClient.setUser).toHaveBeenLastCalledWith(expect.objectContaining({ email: staleUser.email }));
+ expect(localStorageMock.getItem('regengine_user')).toBe(JSON.stringify({ ...staleUser, email: '' }));
+ expect(window.sessionStorage.getItem('regengine_user')).toBeNull();
+ });
+
+ it('does not replace a completed login with the redacted cache after migration awaits', async () => {
+ const loginUser = {
+ id: crypto.randomUUID(),
+ email: `${crypto.randomUUID()}@example.test`,
+ is_sysadmin: false,
+ status: 'active',
+ };
+ localStorageMock.setItem('regengine_access_token', crypto.randomUUID());
+ const defaultFetch = mockFetch.getMockImplementation()!;
+ let resolveMigration: ((value: { ok: boolean }) => void) | undefined;
+ mockFetch.mockImplementation((url: string, options?: RequestInit) => {
+ if (options?.method === 'POST' && !resolveMigration) {
+ return new Promise((resolve) => { resolveMigration = resolve; });
+ }
+ return defaultFetch(url, options);
+ });
+ const { result } = renderHook(() => useAuth(), { wrapper });
+ await waitFor(() => expect(resolveMigration).toBeDefined());
+
+ await act(async () => {
+ await result.current.login(crypto.randomUUID(), loginUser);
+ resolveMigration!({ ok: true });
+ });
+ await waitFor(() => expect(result.current.isHydrated).toBe(true));
+
+ expect(result.current.user).toEqual(loginUser);
+ expect(apiClient.setUser).toHaveBeenLastCalledWith(loginUser);
+ expect(JSON.parse(localStorageMock.getItem('regengine_user')!)).toEqual({ ...loginUser, email: '' });
+ expect(window.sessionStorage.getItem('regengine_user')).toBeNull();
});
it('does not wipe a login that completed while the hydrate session check was in flight', async () => {
@@ -968,7 +1013,9 @@ describe('useAuth Hook', () => {
});
expect(result.current.user?.id).toBe('fresh-user');
- expect(localStorageMock.getItem('regengine_user')).toBe(JSON.stringify(mockUser));
+ expect(localStorageMock.getItem('regengine_user')).toBe(JSON.stringify({ ...mockUser, email: '' }));
+ expect(result.current.user?.email).toBe(mockUser.email);
+ expect(apiClient.setUser).toHaveBeenLastCalledWith(expect.objectContaining({ email: mockUser.email }));
document.cookie = 're_csrf=; expires=Thu, 01 Jan 1970 00:00:00 GMT';
});
diff --git a/frontend/src/__tests__/lib/client-session-profile.test.ts b/frontend/src/__tests__/lib/client-session-profile.test.ts
new file mode 100644
index 000000000..bd7736760
--- /dev/null
+++ b/frontend/src/__tests__/lib/client-session-profile.test.ts
@@ -0,0 +1,71 @@
+import { beforeEach, describe, expect, it } from 'vitest';
+
+import { seedClientSessionUser, serializeClientSessionUser } from '@/lib/client-session-profile';
+
+function collectEmailValues(value: unknown, acc: unknown[] = []): unknown[] {
+ if (Array.isArray(value)) {
+ for (const item of value) collectEmailValues(item, acc);
+ } else if (value !== null && typeof value === 'object') {
+ for (const [key, entry] of Object.entries(value as Record)) {
+ if (key.toLowerCase() === 'email') acc.push(entry);
+ collectEmailValues(entry, acc);
+ }
+ }
+ return acc;
+}
+
+describe('seedClientSessionUser', () => {
+ beforeEach(() => {
+ window.localStorage.clear();
+ window.sessionStorage.clear();
+ });
+
+ it('stores a non-sensitive placeholder profile without the email', () => {
+ const email = `${crypto.randomUUID()}@example.test`;
+
+ seedClientSessionUser(email);
+
+ const stored = window.localStorage.getItem('regengine_user');
+ expect(stored).not.toBeNull();
+ expect(stored as string).not.toContain(email);
+ expect(stored as string).not.toContain(email.split('@')[0]);
+ expect(window.sessionStorage.getItem('regengine_user')).toBeNull();
+
+ const parsed = JSON.parse(stored as string) as Record;
+ expect(parsed).toMatchObject({
+ id: 'email-session',
+ email: '',
+ is_sysadmin: false,
+ status: 'active',
+ role_name: 'member',
+ permissions: [],
+ });
+
+ const emailValues = collectEmailValues(parsed);
+ expect(emailValues.length).toBeGreaterThan(0);
+ for (const value of emailValues) {
+ expect(value).toBe('');
+ }
+ });
+});
+
+describe('serializeClientSessionUser', () => {
+ it('omits email without mutating the authenticated profile or its permissions', () => {
+ const user = {
+ id: crypto.randomUUID(),
+ email: `${crypto.randomUUID()}@example.test`,
+ is_sysadmin: false,
+ status: 'active',
+ role_name: 'member',
+ permissions: ['reports.read'],
+ };
+ const original = structuredClone(user);
+
+ const stored = serializeClientSessionUser(user);
+
+ expect(JSON.parse(stored)).toEqual({ ...original, email: '' });
+ expect(stored).not.toContain(original.email);
+ expect(stored).not.toContain(original.email.split('@')[0]);
+ expect(user).toEqual(original);
+ });
+});
diff --git a/frontend/src/lib/auth-context.tsx b/frontend/src/lib/auth-context.tsx
index f9d671e0c..f4d7b064d 100644
--- a/frontend/src/lib/auth-context.tsx
+++ b/frontend/src/lib/auth-context.tsx
@@ -5,6 +5,7 @@ import type { User, SessionAuthMethod } from '@/types/api';
import { apiClient } from './api-client';
import { fetchWithCsrf } from './fetch-with-csrf';
import { sanitizePermissions } from './permissions';
+import { serializeClientSessionUser } from './client-session-profile';
interface AuthContextType {
user: User | null;
@@ -42,7 +43,7 @@ type LoginOptions = {
* ONLY in HTTP-only cookies via /api/session. They are never readable
* by client-side JavaScript, eliminating XSS credential theft.
*
- * Non-sensitive data (tenant_id, onboarded, demo_mode, user profile)
+ * Non-sensitive data (tenant_id, onboarded, demo_mode, profile without email)
* remains in localStorage for fast hydration.
*
* The deprecated apiKey field in the context is still set to a placeholder
@@ -285,8 +286,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
if (storedUser) {
try {
const parsedUser = normalizeUserProfile(JSON.parse(storedUser) as User);
- setUserState(parsedUser);
- apiClient.setUser(parsedUser);
+ localStorage.setItem(STORAGE_KEYS.USER, serializeClientSessionUser(parsedUser));
+ if (!loginCompletedRef.current) {
+ setUserState(parsedUser);
+ apiClient.setUser(parsedUser);
+ }
} catch { /* ignore corrupt data */ }
}
@@ -307,7 +311,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const normalizedSessionUser = normalizeUserProfile(session.user);
setUserState(normalizedSessionUser);
apiClient.setUser(normalizedSessionUser);
- localStorage.setItem(STORAGE_KEYS.USER, JSON.stringify(normalizedSessionUser));
+ localStorage.setItem(STORAGE_KEYS.USER, serializeClientSessionUser(normalizedSessionUser));
}
setAccessTokenState(COOKIE_MANAGED_PLACEHOLDER);
apiClient.setAccessToken(COOKIE_MANAGED_PLACEHOLDER);
@@ -411,7 +415,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
if (loginTenantId) apiClient.setCurrentTenant(loginTenantId);
if (typeof window !== 'undefined') {
- localStorage.setItem(STORAGE_KEYS.USER, JSON.stringify(normalizedLoginUser));
+ localStorage.setItem(STORAGE_KEYS.USER, serializeClientSessionUser(normalizedLoginUser));
if (loginTenantId) localStorage.setItem(STORAGE_KEYS.TENANT_ID, loginTenantId);
}
@@ -443,6 +447,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}
loginCompletedRef.current = true;
+ // Hydration may have read the redacted cache while the cookie write awaited.
+ apiClient.setUser(normalizedLoginUser);
// React state updates - triggers re-renders and the navigation useEffect.
// Cookie should be stored in the browser at this point.
diff --git a/frontend/src/lib/client-session-profile.ts b/frontend/src/lib/client-session-profile.ts
index 0bb6c4bc3..197928700 100644
--- a/frontend/src/lib/client-session-profile.ts
+++ b/frontend/src/lib/client-session-profile.ts
@@ -1,16 +1,26 @@
import type { User } from '@/types/api';
+// Keep the authenticated profile intact in memory; cache no email in web storage.
+export function serializeClientSessionUser(user: User | null): string {
+ return JSON.stringify(user ? { ...user, email: '' } : null);
+}
+
+// Seeds a non-sensitive placeholder profile only: the email argument is
+// intentionally never persisted to web storage nor held in module state
+// (CodeQL js/clear-text-storage-of-sensitive-data). The real session fetch
+// provides the authenticated user in memory.
export function seedClientSessionUser(email: string): void {
+ void email;
if (typeof window === 'undefined') return;
const user: User = {
id: 'email-session',
- email,
+ email: '',
is_sysadmin: false,
status: 'active',
role_name: 'member',
permissions: [],
};
- window.localStorage.setItem('regengine_user', JSON.stringify(user));
+ window.localStorage.setItem('regengine_user', serializeClientSessionUser(user));
}