Skip to content
All work
24Community membership platform2026

IKALE

A Fastify and Prisma backend for community membership, built around account recovery that never reveals whether an account exists — OTPs hashed at rest, delivery moved off the response path.

357
Commits
74
TypeScript files
5
Route groups
0
Plaintext OTPs stored
Role
Backend engineer
Year
2026
Status
Private
Type
Community membership platform

01Overview

IKALE is a membership backend built at Newdich Technology for an Ikale community register. Its data model is the clearest statement of what it is for: alongside the usual account fields, a member carries a family name, an ancestral location — ward, town, local government, state — and, separately, a current residential address, state and country.

That separation is the product. A community register that collapses where someone is from into where someone lives cannot answer the question it exists to answer.

The engineering weight sits in authentication and account recovery, which is where I spent most of 357 commits. Fastify 5, Prisma 7 against PostgreSQL, Zod for validation, bcrypt for passwords, JWT for sessions, nodemailer for delivery, and a layered structure — routes, controllers, services, repositories, DTOs, validators, jobs, loaders — kept strict enough that three people could work in it without collisions.

Every endpoint I built is documented in the repository with its request shape, response shape and failure modes, in a per-engineer workspace under docs/.

02The problem

Password recovery is the most attacked endpoint on any application, and the one most often built as a happy path. The standard implementation leaks: send a code to a known address and it returns quickly, send to an unknown one and it returns quickly too — but not quite as quickly, because one of them did real work.

That timing difference is an account enumeration oracle. Given a list of email addresses, an attacker learns which belong to members of a community register. For a register tied to a specific ethnic community and to ancestral locations, that is not an abstract privacy concern.

The second problem is storage. A recovery code sitting in the database in plaintext turns a read-only breach into full account takeover across every account with a live code.

03Architecture

  1. 01

    Recovery that reveals nothing

    Initiating recovery generates a six-digit OTP, hashes it, stores the hash, and returns the same success response whether or not the address belongs to an account. Delivery runs in the background after the response is sent, so response time is a constant that carries no information about account existence.

  2. 02

    OTPs hashed at rest

    The database stores a hash, never the code. A dump of PasswordRecoveryToken yields nothing usable, and verification compares hashes rather than reading a secret back out.

  3. 03

    Delivery that cleans up after itself

    Email is the default channel; SMS is attempted only where a member has a phone number and has explicitly opted in. If every channel fails, the token is deleted rather than left live, and the failure is logged — a code nobody received should not remain valid.

  4. 04

    Layered by responsibility

    Routes, controllers, services, repositories, DTOs, validators, middlewares, jobs, loaders, errors and configs as separate directories. Verbose for a small service, but it is what let three engineers work in parallel and what makes the boundary between HTTP concerns and domain logic non-negotiable.

  5. 05

    Documented per endpoint

    Each endpoint has a markdown document covering overview, method and path, authentication, request, responses and failure modes, kept in the repository beside the code rather than in a wiki that drifts.

04Key decisions

Decision 01

Constant-time responses over fast ones

Moving delivery off the response path costs the ability to tell a caller that sending failed. That is the correct trade: the caller is often an attacker, and the legitimate user finds out by not receiving a code.

Decision 02

Two token types, not one

Password reset and password recovery are separate flows with separate tables and lifetimes. Reusing one token type for both would mean one expiry policy, one revocation rule and one blast radius for two different threat models.

Decision 03

Citext for email

Email is stored as a case-insensitive column at the database level rather than lowercased in application code. Normalisation that lives in one service is normalisation that a second service will eventually forget.

Decision 04

bcrypt here, Argon2 elsewhere

Argon2 is the better choice and what I reach for by default. This service inherited bcrypt from an existing deployment, and a hash migration mid-project would have been a larger risk than the difference between two sound algorithms.

05Stack

  • TypeScript
  • Fastify
  • Prisma
  • PostgreSQL
  • Zod
  • JWT
  • bcrypt
  • Nodemailer