Skip to content

fix: auth security — bcrypt, JWT from env, rate limiting, zod, localStorage#8

Merged
alichherawalla merged 14 commits into
mainfrom
fix/auth-security
Feb 18, 2026
Merged

fix: auth security — bcrypt, JWT from env, rate limiting, zod, localStorage#8
alichherawalla merged 14 commits into
mainfrom
fix/auth-security

Conversation

@alichherawalla
Copy link
Copy Markdown
Contributor

Summary

Full Phase 2d security overhaul for the auth system:

  • bcrypt passwords — signup hashes with cost 10; login uses bcrypt.compare; seed data also hashed
  • JWT_SECRET from env — hardcoded secret removed; getJwtSecret() throws on startup if unset; .env.example added
  • Rate limitingexpress-rate-limit applied to all /api/auth/* routes (20 req / 15 min)
  • Zod validationloginSchema and signupSchema validate request bodies; returns 400 with the first validation error
  • Token persistenceAuthContext writes token + user to localStorage on login/signup, clears on logout, rehydrates on mount
  • Seed guardseed.ts exits with error if NODE_ENV=production
  • Testability — Express app extracted to app.ts so tests can import without binding a port

Tests

  • auth.test.ts (4 unit tests) — authenticateToken: valid token, missing token, invalid token, wrong secret
  • auth.integration.test.ts (5 integration tests, in-memory SQLite) — signup → login → 401 wrong password → hashed in DB → duplicate email 400
  • AuthContext.test.tsx (3 unit tests) — login stores to localStorage, logout clears, mount rehydrates

38/38 tests passing (29 web + 9 server)

Test plan

  • pnpm --filter @stagepass/web test — 29/29 passing
  • pnpm --filter @stagepass/server test — 9/9 passing
  • All auth security issues from docs/ISSUES_IN_THE_CODEBASE.md addressed

🤖 Generated with Claude Code

Installs packages needed for Phase 2d security fixes and server tests:
bcryptjs — password hashing, dotenv — env config, express-rate-limit —
auth rate limiting, zod — request validation, vitest + supertest — tests.
Adds .env.example documenting all required env vars. Server now loads
.env via dotenv/config on startup and reads PORT from the environment
instead of hardcoding 3001.
- JWT_SECRET is now read from process.env; throws on startup if unset
- Signup hashes password with bcrypt (cost factor 10) before storing
- Login fetches user by email only, then compares with bcrypt.compare
- Plain text password comparison in SQL query is gone
20 requests per 15-minute window per IP. Mitigates credential stuffing
and brute-force attacks on login and signup endpoints.
- Login: email must be valid, password must be non-empty
- Signup: name required, email valid, password min 8 chars
- Invalid bodies get a 400 with the first validation error message
- Exits with error code 1 if NODE_ENV === 'production'
- Seed users now have bcrypt-hashed passwords (cost 10)
  so they work with the updated login endpoint
- login and signup write token + user to localStorage
- logout removes both keys
- useEffect on mount reads from localStorage to restore session
  so a page refresh does not log the user out
Separates app creation from server startup. index.ts now just calls
.listen(). Tests can import app.ts directly without binding a port.
Login and signup now write token + user to localStorage. Logout clears
both. A useEffect on mount reads them back so the session survives a
page reload.
Seed users now have bcrypt-hashed passwords (cost 10) so they work with
the new bcrypt.compare() login check. Adds a NODE_ENV=production guard
that exits with an error if someone tries to seed a live database.
auth.test.ts — 4 unit tests for authenticateToken middleware (valid
  token, missing token, invalid token, wrong secret)
auth.integration.test.ts — 5 integration tests against in-memory
  SQLite: signup returns JWT, login succeeds, wrong password 401,
  password is hashed in DB, duplicate email 400
AuthContext.test.tsx — 3 unit tests: login persists to localStorage,
  logout clears localStorage, page reload rehydrates from localStorage
@gemini-code-assist
Copy link
Copy Markdown

Summary of Changes

Hello @alichherawalla, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request significantly enhances the security posture of the authentication system by introducing robust measures such as password hashing, environment-based secret management, and request rate limiting. It also improves data integrity through schema validation and provides a more seamless user experience by persisting authentication state. The changes also lay the groundwork for more reliable testing by refactoring the server application structure.

Highlights

  • Bcrypt Passwords: Implemented bcrypt hashing with a cost of 10 for user passwords during signup and for seed data. Login now uses bcrypt.compare for secure password verification.
  • JWT Secret from Environment: The JWT secret is now loaded from an environment variable (JWT_SECRET), removing the hardcoded value. A getJwtSecret() function ensures the variable is set on startup, and .env.example has been updated.
  • Rate Limiting: Integrated express-rate-limit to all /api/auth/* routes, restricting requests to 20 per 15 minutes to prevent brute-force attacks.
  • Zod Validation: Added zod schemas (loginSchema, signupSchema) to validate request bodies for authentication routes, returning a 400 error with the first validation issue.
  • Token Persistence: AuthContext now stores the authentication token and user data in localStorage upon successful login/signup, clears it on logout, and rehydrates the state from localStorage on component mount.
  • Seed Data Protection: The seed.ts script now includes a guard that prevents it from running if NODE_ENV is set to production, safeguarding production databases.
  • Improved Testability: The main Express application instance has been extracted into a separate app.ts file, allowing test suites to import and test the application without binding to a specific port.

🧠 New Feature in Public Preview: You can now enable Memory to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Changelog
  • .env.example
    • Added JWT_SECRET and NODE_ENV variables to the example environment file.
  • packages/server/package.json
    • Added new dependencies: bcryptjs, dotenv, express-rate-limit, zod, supertest, and vitest.
    • Added corresponding @types for new dependencies.
    • Updated scripts section to include test and test:coverage commands using Vitest.
  • packages/server/src/app.ts
    • Added a new file to encapsulate the Express application setup, including middleware and route registrations.
  • packages/server/src/auth.integration.test.ts
    • Added a new file containing integration tests for the authentication flow, utilizing an in-memory SQLite database and supertest.
  • packages/server/src/auth.test.ts
    • Added a new file containing unit tests for the authenticateToken middleware, mocking database and bcrypt operations.
  • packages/server/src/index.ts
    • Modified to import the Express application from ./app.ts instead of defining it inline.
    • Updated the PORT assignment to use process.env.PORT with a fallback.
  • packages/server/src/routes/auth.ts
    • Imported bcrypt, rateLimit, and zod for enhanced security and validation.
    • Implemented authLimiter middleware to restrict requests to authentication endpoints.
    • Defined loginSchema and signupSchema using Zod for request body validation.
    • Created getJwtSecret() function to securely retrieve the JWT secret from environment variables.
    • Updated /login route to use Zod for input validation and bcrypt.compare for password verification.
    • Updated /signup route to use Zod for input validation and bcrypt.hash for password hashing.
    • Modified authenticateToken middleware to use getJwtSecret() for JWT verification.
  • packages/server/src/seed.ts
    • Added dotenv/config and bcryptjs imports.
    • Implemented a production environment check to prevent accidental seeding in production.
    • Updated user seeding logic to hash passwords using bcrypt.hashSync.
  • packages/web/src/AuthContext.test.tsx
    • Added a new file containing unit tests for AuthContext to verify localStorage persistence and rehydration.
  • packages/web/src/AuthContext.tsx
    • Imported useEffect hook.
    • Added STORAGE_KEY_TOKEN and STORAGE_KEY_USER constants for localStorage keys.
    • Implemented useEffect to rehydrate authentication state from localStorage on component mount.
    • Modified login and signup functions to store the token and user data in localStorage.
    • Modified logout function to clear the token and user data from localStorage.
  • pnpm-lock.yaml
    • Updated the dependency lockfile to reflect the addition of new packages and their transitive dependencies across the project.
Activity
  • The pull request author, alichherawalla, has implemented a comprehensive security overhaul for the authentication system.
  • All 38 tests (29 web + 9 server) are passing, indicating the changes are functional and stable.
  • All identified authentication security issues from docs/ISSUES_IN_THE_CODEBASE.md have been addressed.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request is a major security and robustness upgrade for the authentication system. It correctly introduces password hashing with bcrypt, moves secrets to environment variables, adds rate limiting, and implements input validation with Zod. The code is also refactored for better testability, with comprehensive new tests added. My review focuses on further hardening security against timing attacks, improving type safety by removing as any casts, enhancing error handling and logging, and improving code maintainability through deduplication.

Comment thread packages/server/src/routes/auth.ts Outdated
Comment on lines +45 to +47
const user = db.prepare('SELECT * FROM users WHERE email = ?').get(email) as any;

if (!user) {
if (!user || !(await bcrypt.compare(password, user.password))) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

This implementation is vulnerable to a timing attack. Because the bcrypt.compare function is only called when a user is found, an attacker can measure the response time to determine if a username exists in the database. To mitigate this, you should ensure that bcrypt.compare is always called, regardless of whether the user was found. You can do this by comparing against a dummy hash if the user does not exist.

  // To mitigate timing attacks, always run bcrypt.compare to ensure constant-time responses.
  // If user is not found, compare against a dummy hash. The dummy hash should have the same cost factor.
  const hash = user ? user.password : (process.env.DUMMY_HASH || '$2a$10$invalidhashxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx');
  const match = await bcrypt.compare(password, hash);

  if (!user || !match) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }

// POST login
router.post('/login', (req: Request, res: Response) => {
const { email, password } = req.body;
router.post('/login', async (req: Request, res: Response) => {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current implementation only returns the first validation error message. For a better user experience, it's recommended to return all validation errors. This allows the frontend to display all issues with the submitted form at once. This also applies to the signup route on line 56.

Suggested change
router.post('/login', async (req: Request, res: Response) => {
if (!parsed.success) return res.status(400).json({ errors: parsed.error.flatten() });

router.post('/login', async (req: Request, res: Response) => {
const parsed = loginSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.issues[0].message });
const { email, password } = parsed.data;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using as any bypasses TypeScript's type safety and should be avoided. It's better to define an interface for the expected object shape and cast to that. This improves code readability and maintainability, and helps catch bugs at compile time. A similar issue exists on line 83 for the decoded JWT payload.

Suggested change
const { email, password } = parsed.data;
const user = db.prepare('SELECT * FROM users WHERE email = ?').get(email) as { id: number; name: string; email: string; password: string } | undefined;

// POST signup
router.post('/signup', (req: Request, res: Response) => {
const { name, email, password } = req.body;
router.post('/signup', async (req: Request, res: Response) => {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current implementation only returns the first validation error message. For a better user experience, it's recommended to return all validation errors. This allows the frontend to display all issues with the submitted form at once.

  if (!parsed.success) return res.status(400).json({ errors: parsed.error.flatten() });

@@ -46,7 +82,7 @@ export function authenticateToken(req: any, res: Response, next: NextFunction) {
if (!token) return res.status(401).json({ error: 'No token provided' });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using as any bypasses TypeScript's type safety. It's better to define an interface for the decoded JWT payload and cast to that specific type.

    const decoded = jwt.verify(token, getJwtSecret()) as { userId: number };

});
if (!res.ok) throw new Error('Login failed');
const data = await res.json();
const data = await res.json() as { token: string; user: User };
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Blindly casting the API response with as { token: string; user: User } is unsafe. If the API response format changes or an error is returned, this could lead to runtime errors on the client. It's a good practice to validate data from external sources, including your own API. Consider using a type guard or a validation library like Zod on the client-side as well to ensure the data structure is what you expect before using it. This same issue exists in the signup function on line 62.

alichherawalla and others added 2 commits February 18, 2026 13:03
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…on errors

- Fix timing attack: always call bcrypt.compare even when user not found
  (DUMMY_HASH fallback) so response time is constant regardless of email
- Type UserRow and JwtPayload interfaces; remove all `as any` casts
- Return all zod validation errors as { errors: string[] } instead of
  only the first error message
- Use e instanceof Error guard in catch block instead of `e: any`

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@alichherawalla alichherawalla merged commit 4ba0f0b into main Feb 18, 2026
alichherawalla added a commit that referenced this pull request Feb 18, 2026
Addresses Gemini PR #8 medium-priority comment: the blind
`as { token: string; user: User }` cast is replaced with a
runtime type guard (isAuthResponse). If the server returns an
unexpected shape, an error is thrown before state is updated.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
alichherawalla added a commit that referenced this pull request Feb 18, 2026
## Summary
- Adds `isAuthResponse()` type guard to validate server response has `token: string` and `user: object`
- Replaces blind `as { token: string; user: User }` casts in both `login` and `signup` with runtime check
- Addresses Gemini's medium-priority comment from PR #8

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant