A RESTful API built with Node.js, Express 5, and PostgreSQL. It provides user management with JWT-based authentication delivered through httpOnly cookies, CSRF protection, refresh-token rotation with SHA-256 hashing and reuse detection, password recovery, soft-delete + restore, request validation, audit logging, rate limiting, security headers, request logging, Swagger/OpenAPI docs, and a global error handler.
- Runtime: Node.js (ESM —
"type": "module") - Framework: Express 5
- Database: PostgreSQL via
pg - Auth:
jsonwebtoken(access + refresh JWTs in httpOnly cookies) +bcrypt - Cookies/CSRF:
cookie-parser,csurf - Validation:
joi&zod - Security/Infra:
cors(origin allowlist + credentials),helmet,express-rate-limit - Logging:
morgan(dev only) - API Docs:
swagger-jsdoc,swagger-ui-express - Dev:
nodemon
.
├── app.js # Express app entry point
├── config/
│ └── swagger.js # Swagger / OpenAPI spec
├── constants/
│ ├── allowedOrigins.js # CORS origin allowlist
│ ├── auditActions.js # Audit-log action enum
│ └── errorCodes.js # Centralized error codes
├── controllers/
│ ├── authController.js # Register, login, logout, password & token flows
│ └── userController.js # User CRUD + restore
├── db/
│ └── index.js # PostgreSQL pool
├── middleware/
│ ├── asyncHandler.js # Async wrapper
│ ├── authMiddleware.js # JWT auth from accessToken cookie
│ ├── authorizeSelf.js # Owner-only guard
│ ├── errorHandler.js # Global error handler
│ ├── validate.js # Schema validator
│ └── validateId.js
├── models/
│ ├── auditLogModel.js # Audit-log SQL queries
│ └── userModel.js # User SQL queries (incl. hashed refresh token)
├── routes/
│ ├── authRoutes.js
│ └── userRoutes.js
├── services/
│ ├── auth/
│ │ └── authService.js # Auth business logic
│ ├── auditLogService.js # Fire-and-forget audit logger
│ └── userService.js
├── utils/
│ ├── AppError.js # Custom error class
│ ├── cookies.js # Shared cookie options (access/refresh)
│ ├── formatZodError.js
│ ├── hash.js # SHA-256 token hashing
│ ├── jwt.js # Access/refresh token sign & verify
│ ├── resetToken.js # Reset-token generation
│ └── response.js # Unified success response
├── validators/
│ ├── auth.validator.js
│ └── user.validator.js
└── package.json
- Node.js 18+
- PostgreSQL 13+
- Yarn or npm
git clone <your-repo-url>
cd node-api-postgres
yarn install
# or: npm installCreate a .env file in the project root:
# Single PostgreSQL connection string (SSL is enabled automatically when NODE_ENV=production)
DATABASE_URL=postgres://user:password@localhost:5432/crud_api
PORT=3000
# Separate secrets for access and refresh tokens
JWT_ACCESS_SECRET=your_long_random_access_secret
JWT_REFRESH_SECRET=your_long_random_refresh_secret
# "development" enables morgan request logging; "production" marks cookies as Secure and enables DB SSL
NODE_ENV=developmentJWTs expire in 5 minutes (access) and 1 day (refresh). The cookies that carry them have maxAge of 15 minutes and 7 days respectively, so the JWT lifetime is the binding limit.
Create the database and tables:
CREATE DATABASE node_api;
CREATE TABLE users (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(150) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
is_deleted BOOLEAN DEFAULT FALSE,
reset_token VARCHAR(255),
reset_token_expires TIMESTAMP,
refresh_token TEXT,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE audit_logs (
id SERIAL PRIMARY KEY,
actor_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
action VARCHAR(64) NOT NULL,
entity_type VARCHAR(64) NOT NULL,
entity_id INTEGER,
metadata JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMP DEFAULT NOW()
);yarn dev
# or: npm run devServer runs at http://localhost:3000. Interactive API docs are available at http://localhost:3000/api-docs.
Every request flows through (in order):
- CORS — origin allowlist from constants/allowedOrigins.js;
credentials: trueso cookies are sent cross-origin. cookie-parser— parsesaccessToken,refreshToken, and the CSRF cookie.- JSON / urlencoded body parsers — JSON limited to 10kb.
- CSRF (
csurf) — applied to all non-GET/HEAD/OPTIONSrequests, exceptGET /auth/csrf-token. Clients must send the token (default headerX-CSRF-Token/csrf-token) on every state-changing call. - Rate limit — 100 requests per 15 minutes per IP.
helmet— sets a hardened set of HTTP security headers (CSP, HSTS-ready, frameguard, etc.).x-powered-byis also disabled at the app level.morgan— concise request logging, enabled only whenNODE_ENV=development.
Protected routes additionally require the accessToken cookie (set automatically on login/refresh).
Swagger UI is mounted at /api-docs and is generated from JSDoc annotations in the route files via config/swagger.js.
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /csrf-token |
— | Issue a CSRF token (sets the CSRF cookie, returns the token) |
| POST | /register |
— | Register a new user |
| POST | /login |
— | Log in, sets accessToken + refreshToken cookies |
| POST | /refresh-token |
— | Rotate tokens using the refreshToken cookie |
| POST | /logout |
JWT | Invalidate the refresh token and clear cookies |
| POST | /change-password |
JWT | Change password (requires current password) |
| POST | /forgot-password |
— | Generate password reset token (logged to console) |
| POST | /reset-password |
— | Reset password using token |
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | / |
JWT | Paginated list (?page=&limit=) |
| GET | /:id |
JWT | Get user by id |
| PUT | /:id |
JWT+Self | Replace user |
| PATCH | /:id |
JWT+Self | Partial update |
| DELETE | /:id |
JWT+Self | Soft delete |
| PATCH | /:id/restore |
JWT+Self | Restore a soft-deleted user |
Self means the JWT subject (req.user.id) must match the route :id.
These examples use cookies.txt to persist the auth + CSRF cookies between calls.
Fetch a CSRF token (must be done before any POST/PUT/PATCH/DELETE)
curl http://localhost:3000/auth/csrf-token \
-c cookies.txt
# => { "csrfToken": "<token>" }Register
curl -X POST http://localhost:3000/auth/register \
-H "Content-Type: application/json" \
-H "X-CSRF-Token: <token>" \
-b cookies.txt -c cookies.txt \
-d '{"name":"Siam","email":"siam@example.com","password":"Secret123!"}'Login (sets accessToken + refreshToken cookies)
curl -X POST http://localhost:3000/auth/login \
-H "Content-Type: application/json" \
-H "X-CSRF-Token: <token>" \
-b cookies.txt -c cookies.txt \
-d '{"email":"siam@example.com","password":"Secret123!"}'Refresh tokens (reads refreshToken cookie, rotates both cookies)
curl -X POST http://localhost:3000/auth/refresh-token \
-H "X-CSRF-Token: <token>" \
-b cookies.txt -c cookies.txtLogout
curl -X POST http://localhost:3000/auth/logout \
-H "X-CSRF-Token: <token>" \
-b cookies.txt -c cookies.txtList users (GET — no CSRF token required)
curl "http://localhost:3000/users?page=1&limit=10" \
-b cookies.txtAll successful responses follow:
{
"success": true,
"message": "...",
"data": { },
"meta": { }
}Errors are normalized through the global handler with AppError:
{
"success": false,
"message": "Invalid credentials",
"code": "UNAUTHORIZED"
}- Get CSRF token —
GET /auth/csrf-tokensets the CSRF cookie and returns the token. Send it on every state-changing request. - Login — sets two httpOnly cookies:
accessToken(15-min cookie / 5-min JWT) andrefreshToken(7-day cookie / 1-day JWT). The refresh token is stored server-side as a SHA-256 hash. - Authenticated requests — the browser sends the
accessTokencookie automatically; middleware/authMiddleware.js verifies it and populatesreq.user. - Refresh — when the access token expires, call
POST /auth/refresh-token. The server hashes the incoming refresh token, looks it up, issues new tokens, and rotates both cookies. - Token-reuse detection — if a refresh token is presented but no matching hash is found, all of that user's sessions are invalidated (
clearRefreshToken) and aTOKEN_REUSE_DETECTEDaudit entry is written. - Logout — clears the stored refresh token hash and both cookies.
Cookie flags (see utils/cookies.js): httpOnly, sameSite: 'strict', and secure when NODE_ENV=production.
csurf is mounted globally with cookie-stored tokens. Behavior:
GET,HEAD, andOPTIONSrequests bypass the check.GET /auth/csrf-tokenis exempt from the CSRF check itself but is the route that issues the token.- All other methods require the token from
GET /auth/csrf-token, sent in theX-CSRF-Token(orcsrf-token) header. The CSRF cookie must accompany the request. - Missing/invalid tokens return
403 EBADCSRFTOKENfrom the global error handler.
Swagger UI is served at http://localhost:3000/api-docs. The OpenAPI 3.0 spec is generated by swagger-jsdoc from @swagger JSDoc blocks placed above route handlers in routes/, with the base definition in config/swagger.js.
All 14 endpoints (8 auth + 6 user) are documented with request bodies, path/query parameters, response schemas, and the relevant error cases (400 / 401 / 403 / 404 / 409). Reusable components defined in config/swagger.js:
- Schemas:
User,SuccessResponse,PaginatedUsersResponse,ErrorResponse - Security schemes:
cookieAuth(accessTokencookie),apiKeyAuth(x-api-keyheader),csrfToken(x-csrf-tokenheader)
Public auth endpoints (/register, /login, /forgot-password, /reset-password, /csrf-token) override the global security with security: [] so Swagger UI does not require credentials for them.
To document a new endpoint, add a @swagger JSDoc block above its handler — it will be picked up automatically on next start.
Sensitive actions are recorded in the audit_logs table via a fire-and-forget service (services/auditLogService.js). Failures are logged but never break the request. Tracked actions are defined in constants/auditActions.js:
LOGINCREATE_USER,UPDATE_USER,DELETE_USER,RESTORE_USERCHANGE_PASSWORD,RESET_PASSWORDTOKEN_REUSE_DETECTED
Each entry stores the actor, action, entity type/id, and a JSON metadata blob.
- Passwords are hashed with bcrypt (cost 10).
- Access and refresh tokens use separate secrets (
JWT_ACCESS_SECRET,JWT_REFRESH_SECRET). - Tokens travel in httpOnly + sameSite=strict cookies (Secure in production), so they are not reachable from JavaScript.
- Refresh tokens are stored as a SHA-256 hash (utils/hash.js) and rotated on every refresh.
- Refresh-token reuse triggers full session invalidation for that user and an audit-log entry.
- Reset tokens are also stored as a SHA-256 hash; only the raw token leaves the server, and they expire after 15 minutes.
helmetsets hardened HTTP security headers;x-powered-byis disabled.- CORS uses an explicit allowlist (constants/allowedOrigins.js) with
credentials: true. forgot-passworddoes not reveal whether an email exists..envis git-ignored — never commit secrets.
| Script | Description |
|---|---|
yarn dev |
Start dev server with nodemon |
yarn start |
Run the server with node (production) |
MIT