A JWT based authentication API built with TypeScript, Express, and Neon Postgres. It handles signup, login, logout, token refresh, and route protection, with security controls built in rather than added as an afterthought.
- Lets a user create an account with an email and password.
- Hashes passwords with bcrypt before storing them. Plain text passwords are never stored or logged.
- Issues a short lived access token and a long lived refresh token on login.
- Verifies access tokens on protected routes using a signature check, with no database call required.
- Allows logout by revoking the refresh token tied to that session.
- Slows down repeated failed logins per account using exponential backoff, without ever locking an account out completely.
- Documents every route in Swagger UI with bearer token authorization support.
This project was originally scoped around Supabase Auth, which acts as a full identity provider. It hashes passwords and issues tokens on your behalf. Neon is only a Postgres database, so this project builds that missing layer by hand using bcrypt for password hashing and jsonwebtoken for signing and verifying tokens. That is the deliberate difference from the original assignment brief.
- Runtime: Node.js
- Language: TypeScript, ESM modules
- Framework: Express 5
- Database: Neon (serverless Postgres), accessed through the pg driver
- Password hashing: bcryptjs
- Tokens: jsonwebtoken for access tokens, random bytes plus SHA256 for refresh tokens
- Validation: zod
- Security middleware: helmet, express rate limit
- Documentation: swagger ui express
Sign up at neon.tech, create a new project, and copy the pooled connection string from the project dashboard.
Copy .env.example to .env and fill in real values.
DATABASE_URL=your_neon_pooled_connection_string
JWT_ACCESS_SECRET=generate_a_long_random_string
PORT=6767
Generate a secret with:
node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"Run that command twice so JWT_ACCESS_SECRET and JWT_REFRESH_SECRET are different values.
npm installnpm run migratenpm run devThe server starts on http://localhost:6767. Swagger docs are available at http://localhost:6767/docs.
Screenshot of the Swagger UI showing the bearer authorization padlock on protected routes.
| Method | Route | Auth required | Purpose |
|---|---|---|---|
| POST | /auth/signup | none | Create a new account |
| POST | /auth/login | none, rate limited | Log in and receive an access token and refresh token |
| POST | /auth/refresh | none, needs refreshToken | Exchange a valid refresh token for a new access token |
| POST | /auth/logout | Authorization Bearer token | Revoke a refresh token |
| GET | /protected/profile | Authorization Bearer token | Read the logged in user's account details |
| GET | /protected/dashboard | Authorization Bearer token | Second example route using the same guard |
| GET | /public/info | none | Open, unauthenticated route |
Status codes used across the API:
- 201 account created
- 200 successful request
- 204 logout succeeded, no content returned
- 400 missing or invalid input
- 401 missing, malformed, invalid, or expired token, or wrong credentials
- 409 email already registered
- 429 too many requests, either from the IP based rate limiter or the per account login backoff
curl -i -X POST http://localhost:6767/auth/signup \
-H "Content-Type: application/json" \
-d '{"email":"you@example.com","password":"a-strong-password"}'curl -i -X POST http://localhost:6767/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"you@example.com","password":"a-strong-password"}'Response includes both accessToken and refreshToken.
curl -i http://localhost:6767/protected/profile \
-H "Authorization: Bearer PASTE_ACCESS_TOKEN_HERE"curl -i -X POST http://localhost:6767/auth/refresh \
-H "Content-Type: application/json" \
-d '{"refreshToken":"PASTE_REFRESH_TOKEN_HERE"}'curl -i -X POST http://localhost:6767/auth/logout \
-H "Authorization: Bearer PASTE_ACCESS_TOKEN_HERE" \
-H "Content-Type: application/json" \
-d '{"refreshToken":"PASTE_REFRESH_TOKEN_HERE"}'- SQL injection defense: every database query uses parameterized placeholders through the pg driver. No query is ever built by joining strings together.
- Password storage: bcrypt with a cost factor of 12. The plain text password never touches the database or logs.
- Input validation: every request body is validated with zod using strict schemas that reject unknown fields, before any route logic runs.
- Response headers: helmet sets protective headers and removes the X Powered By header that would otherwise reveal the framework in use.
- Rate limiting: a global limiter covers the whole API, with a stricter limiter applied specifically to signup and login.
- Per account login backoff: failed login attempts on a single account trigger an increasing delay, capped at 30 seconds, rather than a hard lockout. This avoids letting an attacker lock a real user out simply by entering the wrong password on purpose.
- Generic authentication errors: a login failure returns the same message whether the email does not exist or the password is wrong, so the API cannot be used to check which emails are registered.
- Body size limits: JSON request bodies are capped to prevent oversized payloads from being used to exhaust server memory.
- Secrets management: all secrets are loaded from environment variables through a gitignored
.envfile..env.exampledocuments the required keys with placeholder values only.
Logging out revokes the refresh token, which stops the client from getting a new access token afterward. The access token the client already holds is a self contained, signed JWT, and verifying it never involves a database lookup, which is what keeps regular requests fast. That token continues to work until it naturally expires. The access token lifetime is kept short specifically to keep that window small. A system that needs to revoke access tokens instantly would need to check every token against a database on every request, which removes the main performance benefit of using JWTs in the first place.
