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, manualcurlverification 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) |
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 intests/auth.test.jsand with a manualcurlround-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.
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.
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/me—SELECT ... WHERE id = req.session.userId. There's no code path where a client-supplied ID reaches this query.GET /api/files—SELECT ... WHERE user_id = req.session.userId.GET /api/files/:id—SELECT ... 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 same404 { error: "File not found." }response — deliberately not403, 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.
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 — seeappwrite-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 model —
fileSecurity: true(so permissions are evaluated per-file, not just per-bucket) plus a bucket-levelPermission.create(Role.users())so any authenticated user can upload, but with no bucket-levelreadgrant to anyone (scripts/setup.js). - Per-file ownership permissions at upload time — each file explicitly
gets
read/update/deletescoped toRole.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/*routescustom-backendexposes, 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.
- Actually run
appwrite-backendagainst 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-backendif 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 toNonefor 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: trueis available as an Appwrite bucket option and was left off inscripts/setup.jsfor simplicity, but should be on for anything beyond a demo. - Secrets management —
.envfiles 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.