Skip to content

Latest commit

 

History

3 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Secure Login System — Two Type Of Implementations

Two backends implementing the same auth/profile/file-access contract:

  • custom-backend/ — Node/Express + PostgreSQL. Fully built and tested end-to-end in this repo's dev environment (real Postgres, 15 automated tests, manual curl verification of every cross-user isolation case). Start here.
  • appwrite-backend/ — Express wrapper over Appwrite's Auth + Storage services. Code-complete but not yet run against a live Appwrite project — see that folder's README for exactly what's unverified and why.

Both expose the identical REST contract, so the same test client can point at either one by changing a base URL:

Route Method Purpose
/api/auth/register POST Create account, start session
/api/auth/login POST Authenticate, start session
/api/auth/logout POST Invalidate session server-side
/api/me GET Own profile only
/api/files GET Own files only
/api/files/:id GET Single file metadata, 404 if not yours
/api/files/:id/download GET File bytes, 404 if not yours
/api/files POST Upload a file (multipart, field name file)

JWT vs. session-based authentication — and why I chose sessions

The task requires that logout invalidate the session server-side, not just clear a client-side token. That single requirement drove the choice.

With JWT, a stateless access token remains cryptographically valid until it expires — "logout" without extra infrastructure just means the client forgets it, but a captured or previously-logged token still works against every protected route until its exp passes. Making JWT logout real means building a denylist (or a rotating allowlist of valid token IDs) that gets checked on every request — at which point the token has stopped being stateless and you've built a session store with more moving parts: token signing/verification, refresh-token rotation, clock-skew handling, and a revocation store, instead of just a revocation store.

I went with cookie-based sessions, stored server-side in Postgres (custom-backend) via connect-pg-simple. The cookie holds only a signed, opaque session ID; all state lives in the session table. This makes:

  • Logout trivially correct: req.session.destroy() deletes the row. A replayed old cookie fails immediately — verified in tests/auth.test.js and with a manual curl round-trip.
  • The security properties easy to reason about and demo live: no token expiry math, no separate revocation system to keep in sync with the primary store.

Trade-off, for balance: JWT is the better fit for a system with multiple independent backend services validating auth without a shared database (session lookups mean a network round-trip per request unless cached), or for fully stateless horizontal scaling. For a single-service app like this one, the operational simplicity of sessions won for me. If this were a distributed system, I'd revisit — likely short-lived JWT access tokens (~15 min) plus a Postgres/Redis-backed rotating refresh token, so revocation only needs to reach the (smaller, less frequently hit) refresh endpoint.

The Appwrite implementation uses sessions for the same reason, but they're Appwrite's own session objects (account.createEmailPasswordSession) — our cookie just carries the session secret Appwrite issued.


How logout is implemented under the hood

custom-backend: POST /api/auth/logout calls req.session.destroy(). express-session + connect-pg-simple deletes the corresponding row from the session table in Postgres, then the response clears the cookie. The row deletion is what matters — even without clearing the cookie, the old session ID would resolve to nothing on the next request, because requireAuth checks req.session.userId, and an empty/missing session object means that check fails. Verified in tests/auth.test.js: log in, confirm /me works, log out, replay the same cookie (via a supertest agent that doesn't drop cookies automatically), confirm /me now returns 401.

appwrite-backend: POST /api/auth/logout calls account.deleteSession('current') using an Appwrite client scoped to the caller's session secret. This deletes that session inside Appwrite itself. Any subsequent request presenting the old session secret fails Appwrite's own account.get() check, which our requireAuth middleware relies on — so the invalidation is enforced by Appwrite, not by us remembering to check a local blocklist.

In both cases: logout is a server-side delete, not a client-side clear. The cookie-clearing on the response is a courtesy for the browser, not the actual security boundary.


How user data isolation is enforced

custom-backend: every query that touches user-owned data is scoped by req.session.userId, which is set once at login and never accepted from client input (not from the body, not from a query param, not from a route param). Concretely:

  • GET /api/meSELECT ... WHERE id = req.session.userId. There's no code path where a client-supplied ID reaches this query.
  • GET /api/filesSELECT ... WHERE user_id = req.session.userId.
  • GET /api/files/:idSELECT ... WHERE id = $1 AND user_id = $2, with both params in the same query. A file that belongs to another user and a file that doesn't exist produce the exact same zero-row result, and both are mapped to the same 404 { error: "File not found." } response — deliberately not 403, so the status code alone can't be used to enumerate which file IDs exist.

This was verified with real cross-user requests (tests/isolation.test.js plus a manual curl session as two different logged-in users), not just assumed from the code shape.

appwrite-backend: isolation is enforced by Appwrite's own permission system rather than a WHERE clause we write. Every protected route builds an Appwrite client scoped to the caller's session (client.setSession(secret)), and all storage reads go through that client. Appwrite evaluates each file's permission list server-side against that session's identity — storage.listFiles() only returns files the caller has read permission on, and storage.getFile() on a file outside that scope returns 404. At upload time, routes/files.js sets each new file's permissions to Permission.read(Role.user(<session's own user ID>)) — never a client-supplied ID — so a file is invisible to everyone except its uploader from the moment it's created.

The philosophical difference: in custom-backend, isolation is a guarantee I have to get right in every query. In appwrite-backend, isolation is a guarantee the platform enforces as long as I set permissions correctly at write time — a smaller, more contained thing to audit.


What Appwrite handled automatically vs. what I configured

Handled automatically by Appwrite:

  • Password hashing and storage (never touches our code or database).
  • Session issuance and validation logic for account.get().
  • Auth-endpoint rate limiting / abuse protection (project-level, under Auth → Security in the console) — I did not hand-roll per-account lockout for this implementation, unlike custom-backend, because Appwrite already enforces this at the platform level. [VERIFY]: the exact thresholds are configured per-project and I haven't confirmed them against a live project in this environment — see appwrite-backend/README.md.
  • Permission-scoped querying for Storage — listFiles()/getFile() automatically filter/reject based on the calling session's identity; I didn't write any user-scoping query logic on the read path.

I configured myself:

  • The storage bucket's permission modelfileSecurity: true (so permissions are evaluated per-file, not just per-bucket) plus a bucket-level Permission.create(Role.users()) so any authenticated user can upload, but with no bucket-level read grant to anyone (scripts/setup.js).
  • Per-file ownership permissions at upload time — each file explicitly gets read/update/delete scoped to Role.user(<uploader's id>) (routes/files.js). Appwrite doesn't infer "the uploader owns this file" on its own; that mapping is something I set.
  • The REST contract wrapper itself — Appwrite's SDKs speak their own shape (Models.Session, Models.User, etc.); translating that onto the same /api/* routes custom-backend exposes, so both implementations are interchangeable from the test client's point of view, was custom work.
  • The admin/session/public client separation in appwriteClient.js — Appwrite doesn't enforce that request-handling code avoid the API-key-authenticated admin client; keeping the running server privilege-minimal (session-scoped only, admin key confined to provisioning scripts) was a deliberate design decision on my part.

What I'd improve given more time

  • Actually run appwrite-backend against a live project and replace every [VERIFY] note with a confirmed result — this is the single biggest gap right now; everything else in this repo was tested, this wasn't, and I don't want to overstate its readiness.
  • Refresh-token rotation / shorter-lived credentials for custom-backend if it ever needed to scale beyond a single service — see the JWT section above.
  • CSRF tokens as defense-in-depth for the cookie-based flows, on top of SameSite=Lax. Lax mitigates most cross-site POST forgery already, but a double-submit CSRF token would close the gap for the edge cases Lax doesn't cover (e.g. some GET-triggered state changes, or if the cookie policy is ever loosened to None for a cross-origin frontend).
  • MFA (TOTP) — meaningfully raises the bar for account takeover beyond password lockout; Appwrite has this built in and it'd be a small lift there, more work on custom-backend.
  • Audit logging of auth events (login, logout, lockout triggered, failed attempts) to a separate append-only store, for incident investigation — currently only stdout via morgan.
  • File virus/content scanning on upload — currently trusts uploaded content type/bytes beyond size limits; antivirus: true is available as an Appwrite bucket option and was left off in scripts/setup.js for simplicity, but should be on for anything beyond a demo.
  • Secrets management.env files are fine for this exercise; production would want these in a proper secrets manager rather than environment variables on disk.
  • Wire up the actual provided index.html — this repo was built before I had access to it; once available it needs to be pointed at each backend's base URL and manually walked through registration, login, viewing files, and logout for both implementations.

About

Secure Login System — A REST-based authentication and file-access system with secure server-side sessions, user profile management, file uploads/downloads, and strict cross-user data isolation. It includes both a custom backend and an Appwrite-based implementation with the same API contract.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages