Most apps make you create an account before you can do anything useful. They ask for your email, make you verify it, pick a password, accept their privacy policy, and by then you've forgotten what you wanted to write down.
Pollo works differently.
You get five random words. That's your account. That's your password. That's your shared space. No registration. No data about you. Open the app, write your list, close it. Come back tomorrow with your five words and everything is there.
horse · rain · table · fire · sun
That's it. That's your workspace.
Using it yourself: Enter your five words → you're in. Every device, every time.
Sharing with someone: Give them your five words → they're in the exact same space. Same lists. Real time. No invites, no accounts, no permissions. Just words.
This makes Pollo genuinely useful for couples, roommates, families, or small teams who want shared lists without the overhead of collaboration software.
This is the technical heart of the project.
Passphrases are generated from a curated dictionary of 7,776 Spanish words sourced from dadoware-bonito-es by @mir123, a Spanish Diceware wordlist based on the EFF standard. Five words are selected using crypto.getRandomValues() with rejection sampling to eliminate modulo bias:
function getSecureRandomIndex(max: number): number {
const bytesNeeded = Math.ceil(Math.log2(max) / 8);
const maxValid = Math.pow(256, bytesNeeded);
const limit = maxValid - (maxValid % max);
while (true) {
const randomBytes = crypto.getRandomValues(new Uint8Array(bytesNeeded));
let value = 0;
for (let i = 0; i < bytesNeeded; i++) {
value = (value << 8) | randomBytes[i];
}
if (value < limit) return value % max;
}
}Entropy: ~12.9 bits per word × 5 words = ~64.5 bits total: 7,776⁵ ≈ 28 trillion combinations. Brute force is not a realistic attack.
The passphrase is never stored in plaintext. Two derived values are computed and stored:
| Field | Algorithm | Purpose |
|---|---|---|
passphrase_hash |
bcrypt (12 rounds) | Verification, slow by design |
passphrase_lookup |
HMAC-SHA256 | O(1) indexed database lookup |
The lookup hash is the key design insight. bcrypt is non-deterministic: you can't WHERE hash = bcrypt(input). That would be a full table scan. HMAC is deterministic: same input + same secret = same output, always. So login is a single indexed query.
flowchart LR
A["User types\n'horse rain\ntable fire sun'"]
A --> B["HMAC-SHA256\n+ SERVER_SECRET"]
A --> C["bcrypt.compare\n+ stored hash"]
B --> D[("Indexed\nDB query")]
D --> E["Find workspace"]
C --> F["Verify identity"]
E & F --> G["Access granted"]
SERVER_SECRET never touches the database. A leaked database gives an attacker bcrypt hashes (expensive to crack) and HMAC digests (useless without the server secret). Two layers, two secrets, zero useful leakage.
| No-account auth | Five-word passphrase, cryptographically generated |
| Multiple lists | Unlimited lists and items per workspace |
| Offline support | Works without internet, syncs on reconnect |
| Recovery kit | Export passphrase as text file or image |
| Bilingual | Full Spanish / English interface with language toggle |
| Mobile-first | Fully responsive and touch-friendly; install it as a PWA from your browser |
| Rate limiting | IP-based brute force protection |
| No tracking | No analytics, no cookies beyond the session |
Not there yet: live sync between devices. The first version relied on the hosted database's change subscriptions, and they never delivered an event: the subscription connected, but row-level security had no policy for anonymous clients. It is being rebuilt on PostgreSQL LISTEN/NOTIFY with Server-Sent Events. Until then, changes from another device show up when the list is reloaded.
Auth layer is fully custom: JWT (jose) + bcrypt (bcryptjs) + HMAC-SHA256 (Web Crypto API). No auth library dependencies.
Production runs on a self-hosted VPS: the app in a container managed by Coolify, TLS through Traefik with Let's Encrypt, and its own PostgreSQL that is not reachable from the internet. Data access goes through a repository layer (lib/repositories) built on postgres.js.
| Concern | Solution |
|---|---|
| Passphrase storage | bcrypt cost factor 12, intentionally slow |
| Passphrase lookup | HMAC-SHA256 with server-side secret |
| Session | JWT HS256, httpOnly cookie, 24h expiry |
| XSS | httpOnly cookies: JS can't read the session token |
| CSRF | SameSite=lax |
| Clickjacking | X-Frame-Options: DENY |
| Brute force | IP-based rate limiting |
| Transport | HSTS 2-year max-age |
| Database exposure | Not reachable from the internet: a default-deny firewall in Docker's own chain, reinstalled on every boot (details) |
| Secrets | Validated at startup: the app refuses to boot if any is missing |
Show schema
workspaces
id UUID PRIMARY KEY
passphrase_hash TEXT -- bcrypt(passphrase, 12 rounds)
passphrase_lookup TEXT UNIQUE -- HMAC-SHA256(passphrase, SERVER_SECRET)
created_at TIMESTAMPTZ
lists
id UUID PRIMARY KEY
workspace_id UUID → workspaces
name VARCHAR(255)
position INT
items
id UUID PRIMARY KEY
list_id UUID → lists
name VARCHAR(500)
is_completed BOOLEAN
completed_at TIMESTAMPTZ -- set and cleared by trigger
position INT
rate_limit_attempts
ip_address INET
success BOOLEAN
attempted_at TIMESTAMPTZ -- pruned after 7 days by a daily jobIndexes on every FK and lookup field. passphrase_lookup has a unique index, so login is always a single indexed query. The full DDL, with the reasoning behind each choice, is in db/schema.sql.
Prerequisites: Node.js 20.9+ and Docker.
git clone https://github.com/patatapython/pollo.git
cd pollo
npm install
# PostgreSQL 16 in Docker, on port 55432
npm run test:db:up
docker exec pollo-pg-dev sh -c 'until pg_isready -h 127.0.0.1 -q; do sleep 1; done'
docker exec -i pollo-pg-dev psql -U postgres -d pollo -v ON_ERROR_STOP=1 < db/schema.sql
cp .env.example .env.local # then fill in your secrets
npm run devEnvironment variables (same as .env.example)
# Required
DB_DRIVER=postgres
DATABASE_URL=postgres://postgres:dev@127.0.0.1:55432/pollo
JWT_SECRET=minimum-32-character-random-string
SERVER_SECRET=minimum-32-character-random-string
# Optional: enables the daily cleanup endpoint /api/cron/cleanup-rate-limits
CRON_SECRET=random-string
# Temporary: still read by the old realtime client (lib/supabase/realtime.ts),
# and the list page fails to load without them. They go away with the SSE rewrite.
NEXT_PUBLIC_SUPABASE_URL=https://xxxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ...Generate secrets: openssl rand -base64 48
DB_DRIVER has to be written down on purpose. The app does not guess the database from whichever variables happen to be set, so a leftover variable can never switch it silently to another one.
Why no user accounts?
User accounts mean email addresses, which means a database worth attacking. Passphrase workspaces mean: no PII stored, nothing useful to leak, zero friction on first use.
The tradeoff is deliberate: lose your five words, lose your workspace. No "forgot password" because there's no email. Feature, not bug.
Why HMAC for lookup instead of bcrypt?
bcrypt is non-deterministic: two hashes of the same input are different values. WHERE hash = bcrypt(input) is impossible. You'd need a full table scan.
HMAC-SHA256 is deterministic. Compute it at login time, query WHERE lookup = hmac(input, secret) on an indexed column. Single query, constant time, scales indefinitely.
The HMAC secret never touches the database. Full DB access + no server secret = no passphrases recovered.
Why a repository layer?
Pollo started on Supabase and moved to its own PostgreSQL on a VPS. Routes never talk to a database client: they ask lib/repositories for workspaces, lists, items and rate limits, and DB_DRIVER chooses the implementation. That made the move a configuration change backed by tests, not a rewrite, and the old implementation stays as the rollback path until the previous platform is switched off.
Why Zustand instead of Redux?
State is small and server-fetched. Zustand's minimal API (no boilerplate, no providers, no actions/reducers split) matches the project's philosophy: minimum necessary, done well.
npm test # unit and API tests
npm run test:ui # Vitest browser UI
npm run test:db:up # start the PostgreSQL 16 test container
npm run test:db # integration tests against that real databaseUnit and API tests cover passphrase crypto, session lifecycle, API validation and failure paths, startup checks, and a scan that fails if the code reads an environment variable nobody declared. The integration suite applies db/schema.sql to an empty database and runs every PostgreSQL repository against it, because a test double would happily confirm a rate limiter that counts nothing.
Portfolio project, but issues and PRs are welcome. For security issues, use a private issue.
MIT, see LICENSE.
Built with the belief that software doesn't need to know who you are to be useful.




