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 |
| Real-time sync | Supabase Realtime via WebSockets |
| 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, touch-friendly — install as PWA from your browser |
| Rate limiting | IP-based brute force protection |
| No tracking | No analytics, no cookies beyond the session |
Auth layer is fully custom: JWT (jose) + bcrypt (bcryptjs) + HMAC-SHA256 (Web Crypto API). No auth library dependencies.
| 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 |
| Secrets | Validated at startup — app refuses to boot if 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 -- auto-set by trigger
position INT
rate_limit_attempts
ip_address INET
success BOOLEAN
attempted_at TIMESTAMPTZ -- auto-cleaned after 24hIndexes on every FK and lookup field. passphrase_lookup has a unique index — login is always a single indexed query.
Prerequisites: Node.js 18+, a Supabase project.
git clone https://github.com/patatapython/pollo.git
cd pollo
npm install
cp .env.example .env.local # fill in your values
npm run devRun migrations: Supabase → SQL Editor → run files in supabase/migrations/ in order.
Environment variables
# Required
NEXT_PUBLIC_SUPABASE_URL=https://xxxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ...
SUPABASE_SERVICE_ROLE_KEY=eyJ...
JWT_SECRET=minimum-32-character-random-string
SERVER_SECRET=minimum-32-character-random-string
# Optional
NEXT_PUBLIC_APP_URL=http://localhost:3000
Generate secrets: openssl rand -base64 48
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 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 # Run all tests
npm run test:ui # Vitest browser UICovers: passphrase crypto, session lifecycle, API validation, env startup checks.
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.




