Prerequisites: Docker with Compose, Node.js 18+, npm, and Python 3.
cp custom-backend/.env.example custom-backend/.env
cd custom-backend
npm install
cd ..
docker compose up -d
cd custom-backend
npm run setup # migrate schema and seed 3 users plus 6 files
npm run dev # API at http://localhost:3000In a second terminal, serve the test client:
cd /path/to/osdag
python3 -m http.server 5500Open http://localhost:5500. To stop PostgreSQL later, run docker compose down from the repository root. The custom seed is repeatable and clears/recreates the development users and files; do not run it against production data.
Appwrite is a separately configured cloud implementation, but its setup and seed scripts are now automatically run by ./start.sh if it is configured.
- Create an Appwrite project and a server API key with the scopes required by the setup scripts.
- Configure its environment:
cp appwrite/.env.example appwrite/.env
# Edit appwrite/.env with the endpoint, project ID, API key, database, collection, and bucket IDs.- Install and initialize Appwrite resources:
cd appwrite
npm install
npm run setup
npm run seedThe repository contains two clearly separated implementations:
custom-backend/: Node.js/Express REST API with PostgreSQL and database-backed sessions.appwrite/: Appwrite setup and seed scripts plus the Appwrite web adapter.
Run this ./start.sh in osdag folder terminal for easy setup
The script checks for Docker, Node/npm, and Python 3; starts PostgreSQL with Docker Compose; runs the custom migration and seed commands; runs the Appwrite setup and seed scripts (if configured); starts the API on http://localhost:3000; and serves the test client on http://localhost:5500. Press Ctrl+C to stop the API and frontend. The PostgreSQL volume remains available for later runs.
- Serve the root test client (
python3 -m http.server 5500), enable the Appwrite SDK andappwrite/web/appwrite-adapter.jsscript tags inindex.html, select Appwrite, and enter the configured resource IDs. Never exposeAPPWRITE_API_KEYin browser code.
Both implementations use these development credentials:
| Password | Seeded files | |
|---|---|---|
alice@example.com |
Password123! |
resume_alice.pdf, profile_photo.jpg |
bob@example.com |
Password123! |
project_notes.txt, invoice_march.pdf |
carol@example.com |
Password123! |
test_plan.docx, vacation.png |
The custom seed writes the six sample files to custom-backend/uploads/ and inserts their metadata into PostgreSQL. Run cd custom-backend && npm run seed after the database is available. The Appwrite seed creates the same three accounts, uploads sample files to the configured bucket, and creates their metadata documents; run cd appwrite && npm run seed after npm run setup. The test client has quick-fill buttons for all three users.
The custom implementation uses opaque, database-backed sessions rather than JWTs. A session can be revoked immediately by changing one database row, which gives logout and account compromise response strong semantics. The session ID is random, stored only as a SHA-256 hash in PostgreSQL, and sent to the browser in an HttpOnly, SameSite=Lax cookie. JWTs are useful for independently verifiable, distributed services, but a stolen JWT normally remains valid until expiry; immediate revocation requires a blocklist or short-lived-token and refresh-token design.
Custom logout hashes the session cookie value, updates the matching sessions row with revoked_at = NOW(), and clears the cookie. Subsequent protected requests fail session validation even if an old cookie was copied. Appwrite logout calls deleteSession('current'), which revokes the current Appwrite session, and the adapter clears its client-side session state.
The custom backend derives the authenticated user ID only from the validated session. Protected file queries include the owner predicate (user_id = authenticated user ID) in the SQL query, and stored file paths are resolved only after that ownership check. This prevents a caller from changing a file ID to access another user's file. Appwrite uses documentSecurity: true, no broad collection permissions, and per-document and per-storage-file permissions for Role.user(ownerId); Appwrite enforces those checks server-side rather than relying on frontend filtering.
Appwrite automatically provides password hashing, account creation, session cookies, session deletion, email uniqueness, and platform rate limiting. This repository configures the database, file metadata collection and attributes, owner index, storage bucket, file size/type limits, document security, file security, per-user permissions, and adapter mapping. The full Appwrite details are also documented in appwrite/README.md.
I would add email verification and password reset flows, CSRF tokens for deployments requiring cross-site cookie use, real upload validation and object storage lifecycle handling, Redis-backed session/rate-limit storage for multiple API instances, structured end-to-end tests for both implementations, and a production deployment guide with HTTPS and secret management. I would also make the Appwrite browser integration a separate bundled client instead of requiring manual script-tag changes in the test page.
What the project is: A complete, highly secure authentication platform and file-sharing API. It implements user registration, login, profile management, and protected file access. What problem it solves: It provides a robust, production-ready solution to common security vulnerabilities like Session Hijacking, Cross-Site Scripting (XSS), Cross-Site Request Forgery (CSRF), and Insecure Direct Object References (IDOR). Why I built it: To demonstrate a deep understanding of web security, server-side session management, and backend architecture using Node.js and PostgreSQL. Real-world use cases: Can serve as the foundational authentication and authorization layer for SaaS applications, internal company portals, or confidential document-sharing platforms.
What was difficult before this project: Implementing authentication securely is notoriously difficult. Storing tokens in localStorage leaves users vulnerable to XSS. Using stateless JWTs makes it impossible to instantly revoke a compromised user session. Relying solely on client-side logic can easily lead to data leaks via IDOR.
How my project solves it:
- Uses Server-Side Sessions mapped to a PostgreSQL database, allowing instant server-side revocation (true logout).
- Uses HttpOnly, SameSite cookies to store session identifiers, completely neutralizing XSS token theft.
- Implements strict SQL-level ownership checks to prevent IDOR on file access. What makes the solution useful: It provides a template that prioritizes security over convenience without sacrificing performance, meeting enterprise-grade security requirements.
- Secure User Registration: Passwords are mathematically hashed using
bcrypt(cost factor 12) before ever touching the database. - Server-Side Session Management: Cryptographically secure random session IDs are generated, hashed via SHA-256, and stored in PostgreSQL.
- True Logout Mechanism: When a user logs out, their session is explicitly marked as
revoked_at = NOW()in the database. The session instantly becomes useless even if the cookie is intercepted. - Strict Data Isolation (IDOR Protection): Users can only access files they explicitly own. Every database query enforces
user_id = req.user.id. - Advanced Rate Limiting: Global IP-based rate limiting prevents DDoS, and account-based rate limiting (5 attempts before lockout) prevents brute-force password guessing.
- Security Headers & CORS: Configured with
helmetfor HTTP headers and strict CORS origin/credentials enforcement.
- Frontend: HTML5, Vanilla JavaScript (Mock client provided for testing the API).
- Backend: Node.js, Express.js. Chosen for their non-blocking I/O and vast ecosystem.
- Database: PostgreSQL. Chosen because authentication and user-file relationships are strictly relational. It guarantees ACID compliance.
- Libraries/Frameworks:
bcrypt: Industry standard for secure password hashing.express-rate-limit: For IP-level brute force protection.helmet: Automatically sets secure HTTP headers.cors&cookie-parser: To securely handle cross-origin requests and HttpOnly cookies.pg: Native PostgreSQL client to execute parameterized queries.jest&supertest: For robust API and security testing.
- Deployment: Docker (via Docker Compose) to seamlessly run the PostgreSQL instance across environments. Python
http.serverfor the static frontend.
Architecture Flow:
- Client makes an HTTP request to the Express API.
- The request passes through Helmet (Headers), CORS, Cookie Parser, and Rate Limiter.
- It hits the Route Layer which routes to a specific endpoint (e.g.,
/files/:id). - If protected, the Auth Middleware extracts the session cookie, queries the PostgreSQL DB, and validates the session. If valid, it attaches the
req.user. - The Controller receives the request, validates the URL parameters, and calls the Service Layer.
- The Service Layer executes business logic and queries PostgreSQL using parameterized SQL.
- The DB responds, the Service returns data to the Controller, and the Controller sends an HTTP response to the Client.
graph TD
Client[Client Browser / Frontend] -->|HTTP Request| ExpressApp[Express App]
subgraph Express Backend
ExpressApp --> Middlewares[Middlewares: Helmet, CORS, Rate Limiter]
Middlewares --> Router[Router]
Router --> AuthMid[Auth Middleware]
AuthMid -->|Session Check| DB[(PostgreSQL)]
AuthMid -->|If Valid| Controllers[Controllers]
Controllers --> Services[Services Business Logic]
Services -->|Parameterized Queries| DB
end
DB --> Services
Services --> Controllers
Controllers --> Client
- Project Setup & Docker: Set up
package.jsonand created adocker-compose.ymlto spin up a local PostgreSQL container (secure_auth_db). - Database Design: Created
schema.sqldefiningusers,sessions,files, andlogin_attemptstables with Foreign Key constraints and Indexes. Wrote amigrate.jsscript to auto-apply schemas. - Backend Structure: Adopted an MVC-like 3-layer architecture:
Routes->Controllers(request handling) ->Services(business logic/DB queries). - Middleware Implementation: Configured
app.jswithhelmet,cors, and global error handling. Built customauth.middleware.jsto parse cookies and validate sessions against the DB. - Authentication Flow: Built registration (hashing passwords) and login (creating sessions). Stored session IDs securely in cookies.
- Data Access (IDOR Prevention): Implemented the
file.service.jswhich strictly appendsAND user_id = $2to SQL queries when fetching files. - Rate Limiting & Security: Implemented IP-based and Account-based rate limiting to prevent brute-force login attempts.
- Testing: Wrote Jest/Supertest integration tests to verify successful flows and edge cases (e.g., attempting to download someone else's file).
1. True Server-Side Logout (Session Revocation)
What it does: Immediately invalidates a user's session.
How it works: Instead of just clearing the cookie on the client (which a hacker could have copied), the backend executes: UPDATE sessions SET revoked_at = NOW() WHERE token_hash = $1.
Why it’s better: If we used stateless JWTs, a stolen JWT remains valid until it expires. By checking the database on every request, we can lock a compromised account instantly.
2. Anti-IDOR Database Query
What it does: Prevents User A from reading User B's files by modifying the ID in the URL (/files/2).
How it works:
// Inside file.service.js
const result = await pool.query(
'SELECT * FROM files WHERE id = $1 AND user_id = $2',
[fileId, req.user.id] // req.user.id is securely derived from the session!
);Why it’s better: We don't fetch the file and then check ownership in code (which is prone to logic errors). We enforce ownership at the database level.
- User Action: Clicks "Download File" on the UI.
- Frontend: Sends a
GET /files/:id/downloadrequest to the backend. The browser automatically attaches theHttpOnlysession cookie. - Auth Middleware: Reads the cookie, hashes the session ID, checks the
sessionstable in PostgreSQL. Ensuresrevoked_atis NULL andexpires_atis in the future. Attaches the verified user ID toreq.user. - API Route/Controller: The request reaches the File Controller, which grabs the file ID from the URL (
req.params.id) and passes it to the File Service. - Service/Database: The Service queries
SELECT * FROM files WHERE id = $1 AND user_id = $2. - Response: If a row is returned, the controller sends the file metadata/content via a
200 OK. If no row is returned, the controller checks if the file exists at all. If it does, it returns403 Forbidden(wrong owner), otherwise404 Not Found.
- Why PostgreSQL? The relationships between Users, Sessions, and Files are highly structured. Relational constraints (Foreign Keys) maintain data integrity if a user is deleted (cascading deletes for sessions and files).
- Why Server-Side Sessions over JWT? Security. The core requirement was absolute control over sessions (instant revocation upon logout or password change). JWTs are stateless and cannot be revoked without a complex blocklist mechanism.
- Why MVC (Routes-Controllers-Services)? Separation of concerns. Controllers only handle HTTP (req/res). Services only handle business logic and DB queries. This makes unit testing services extremely easy without mocking HTTP objects.
- Why HttpOnly Cookies? To completely mitigate Cross-Site Scripting (XSS). If a token is in
localStorage, malicious JS can read it.HttpOnlycookies are invisible to JavaScript.
- Challenge: Handling CORS issues when the frontend and backend run on different ports.
Solution: Configured the
corsmiddleware explicitly allowing the frontend origin (http://localhost:5500) and settingcredentials: true. This was required for the browser to send cookies cross-origin. - Challenge: Preventing brute-force attacks against user accounts.
Solution: Implemented a two-tier rate limiting system. A global IP rate limiter (
express-rate-limit) prevents DDoS, and a custom DB-backed Account limiter locks an email address for 5 minutes after 5 consecutive failed logins. - Challenge: Protecting session tokens in the database. Solution: Even if the database is leaked, raw session IDs shouldn't be exposed. I hashed the session IDs with SHA-256 before storing them in the DB. The client holds the raw ID in the cookie.
- Authentication:
bcryptfor password hashing. Cryptographically secure random strings for session IDs. - Authorization (IDOR prevention): Strict SQL-level
user_idfiltering for all protected resources. - Input Validation: Parsed and validated request bodies. Handled unhandled exceptions gracefully.
- XSS Protection:
HttpOnlysession cookies. - CSRF Protection:
SameSite=Laxcookie attribute. - SQL Injection Prevention: Used
pgparameterized queries ($1, $2) exclusively. String concatenation is never used in SQL. - Environment Variables: Secrets (DB credentials, Ports) loaded via
.envand never committed to source control.
- What could become slow: Querying the
sessionstable on every protected request. - How it's handled: Created database indexes on
token_hashin thesessionstable, andemailin theuserstable to ensureO(log N)lookup times. - Future optimization: If read loads increase, session validation could be moved to an in-memory datastore like Redis to bypass PostgreSQL for auth checks.
- Integration Testing: Used
JestandSupertestto write API tests. - Security Testing: Specifically wrote tests to attempt IDOR (User A fetching User B's file), which correctly asserted
403 Forbiddenresponses. - Manual Testing: Used the provided
index.htmlUI to manually verify cookie storage, login state persistence, and logout flow.
To run this locally for development or demonstration:
- Ensure Docker and Node 18+ are installed.
- Inside
custom-backend, runnpm install. - Start the PostgreSQL database:
docker compose up -d. - Run DB migrations and seeding:
cd custom-backend && npm run setup. - Start the frontend and backend simultaneously using the provided script:
./start.sh(from the project root). - Visit
http://localhost:5500in the browser.
secure-auth-platform/
├── docker-compose.yml # Postgres Database container setup
├── start.sh # Script to run API and UI concurrently
├── custom-backend/ # Node.js API Codebase
│ ├── package.json # Dependencies and scripts
│ ├── src/
│ │ ├── app.js # Express app & middleware setup
│ │ ├── config/ # Env variables and DB connection pool
│ │ ├── controllers/ # HTTP Req/Res handlers (auth, file, user)
│ │ ├── middleware/ # Auth validation, Error handling, Rate limiting
│ │ ├── routes/ # Express route mapping
│ │ ├── services/ # Core business logic and SQL queries
│ │ └── utils/ # Loggers, formatters
│ ├── db/
│ │ ├── schema.sql # Table definitions (users, sessions, files)
│ │ └── migrate.js # Script to create tables
│ └── tests/ # Jest integration tests
└── web/ # Frontend test client
└── index.html # UI to interact with the API
- The fundamental differences between stateful (Session) and stateless (JWT) authentication, and when to use each.
- How to write raw SQL queries safely using parameterization to prevent SQL injection.
- How CORS actually works under the hood, particularly regarding the
credentialsflag and cross-origin cookies. - Architectural patterns like separating Routes, Controllers, and Services to make a codebase testable and maintainable.
- Redis Integration: Caching session data in Redis for faster, scalable auth checks.
- File Upload Service: Implementing
multerto allow users to actually upload files to an S3 bucket instead of using seeded data. - Refresh Tokens: If moving to a mobile app, implementing a short-lived access token / long-lived refresh token architecture.
- OAuth Integration: Adding "Login with Google/GitHub" functionality.