Mo Sharif
Back to writing

Duress PIN Design: Why a Spinner Is a Security Bug

In this article

Almost everyone I talked to about DateSafe suggested the same feature: let people rate their dates. Build profiles of people to avoid. Crowdsource the red flags. It sounds like safety.

I said no, and it is the most important decision in the whole product.

DateSafe

is a dating-safety app that coordinates a private plan, timed check-ins, and escalation with a trusted circle you choose, instead of building a searchable database of people to avoid.

DateSafe has no ratings and no profiles of people, because a searchable database of unverified claims about named individuals, with no due process, is not a safety tool. It is a weapon waiting for its first misuse.

So I wrote the rule into the spec on day one: DateSafe is private planning, trusted-circle sharing, check-ins, and user-controlled escalation. It is not a public database of anyone.

That "no" forced the reframe that made everything else work: dating safety is not information about strangers, it is coordination with people who love you. Once I believed that, the app designed itself.

The constraint you write down first is usually the thing that ends up designing the product. It is the same move that produced AbleMakers, built around makers with Down syndrome rather than around everyone.

A DateSafe safety session combines a private plan, an opted-in trusted circle, and timed check-ins. Missed check-ins and user-triggered alerts enter the notification path. The product helps coordinate a response; delivery still depends on the device, network, and notification providers.

When you get home safe, the session ends and everyone relaxes.

The engineering interest is entirely in the words "fast" and "when something goes wrong."

DateSafe keeps scheduled escalation state in Postgres so due work, session state, and delivery records can be inspected together. The current processor claims work with a conditional update, rechecks the live session, and records notification work for a separate delivery worker.

The stack is a pnpm monorepo: NestJS, Prisma, and Postgres for the API, Expo and React Native for the app, and Next.js for the site. Shared Zod DTOs reduce contract drift, while deployed versions and runtime behavior still need testing.

When a session starts or a check-in changes, scheduled rows carry a due time and action. The processor selects a candidate and conditionally changes its state from pending to processing. Only a worker whose update succeeds owns that claim.

That is compare-and-swap, not an exactly-once delivery guarantee. A worker can fail after a notification provider accepts a message but before the acceptance is recorded.

BoundaryWhat the design checksWhat remains separate
Due scheduleConditional row claim and stale-lock recoveryWhether the session still needs escalation
Session decisionCurrent status checked before alert workWhether a notification provider is reachable
Delivery outboxDurable encrypted message records and deduplication keysWhether an accepted message reaches a device
Provider resultStatus recorded for follow-upWhether a person saw the alert or could respond

Postgres also supports SKIP LOCKED for queue-like tables. That is another claiming technique, not a shortcut around the database-to-provider failure boundary.

The current implementation separates the schedule tick from a delivery worker that wakes every two seconds. Retry behavior, deduplication, and delivery status need their own tests. The value of the database is durable, inspectable state, not magic exactly-once semantics.

Everything external in DateSafe is a port with a fake behind it: SMS, push, key management, even the AI. In development the SMS gateway is a single line of stdout, so the whole product runs end to end with no provider account at all.

You ask for a login code and the server prints [dev sms] to +1555... Your DateSafe code is 123456. I built and tested an SMS-heavy product for weeks without a Twilio bill. In production the same port is Twilio, chosen by an environment variable.

Putting a neutral interface in front of anything you might swap is the whole thesis of ResolveMesh, which resolves an intent to a capability without caring who provides it.

The DateSafe duress PIN is a second passcode that ends a safety session exactly like the real one on screen, while silently telling your circle that something is wrong. To anyone watching over your shoulder, you typed a code and closed an app.

You set two PINs. The real one ends a session normally. The duress one ends it too, and looks identical doing it. It looks like a normal goodbye.

A duress screen should not reveal its purpose through extra loading states or visible notifications. Someone nearby may be watching the device. Keeping provider work off the response path removes one source of visible difference, but does not prove perfect timing indistinguishability.

The engineering payoff is concrete: record the work durably while keeping provider latency out of the interaction someone else may be watching.

So the code carries a comment I am oddly proud of:

// TIMING SAFETY: persist the alert and encrypted outbox rows locally,
// but never invoke an SMS or push provider in this response path.

The current path commits the session change, alert, and encrypted delivery work together, then returns the same outcome shape as the normal PIN path. A worker handles provider calls separately. That keeps a slow SMS API away from the screen without relying on a forgotten promise surviving a process exit.

The PIN screen is not the only place this rule applies. "Duress indistinguishability" is a constraint on every surface. Duress and safe-word alerts never fire a visible push, because a lock-screen banner would betray you.

The home-safe screen renders identically whether you ended normally or under duress. Even the newer Person History feature was built so it cannot look different across the real-PIN, duress-PIN and no-PIN paths. The quiet option has to be quiet everywhere, or it is not quiet at all.

DateSafe uses envelope encryption, a blind index and one-way hashes, because privacy here is the schema rather than a compliance checkbox, and the three things I needed to protect have genuinely different requirements.

TechniqueWhat it protectsWhy this oneHow it works
Envelope encryptionDate details, the person's info, location points, the journalThese have to come back out in plaintext for you, so they need real reversible encryptionEach user gets a data key, and that key is wrapped by a key-management key: a local stub in dev, a cloud KMS in prod
Blind indexPhone numbersAn inbound safe-word text or a contact invite has to match a user by number, without the number being searchableAn HMAC of the normalized number is stored next to the ciphertext, so equality matching works with no plaintext to leak
One-way hashesPINs and share-link tokensNothing ever needs to read these back, only verify themPINs go through argon2; share-link tokens are stored as hashes only, and the raw token rides in the SMS link exactly once

The rule underneath all three rows: if a value never has to be read back, do not store anything that can be.

DateSafe keeps model decisions out of the escalation path. Timers and explicit user actions drive alerts; non-emergency helpers sit behind a separate provider interface. Testing with the AI worker unavailable checks that separation, but it does not certify the reliability of SMS, push, or emergency response.

The AI does non-emergency helpers behind a swappable provider port: a safety-plan drafter and a message red-flag coach. It never decides whether a person is safe, and it never triggers an emergency. Those flows are deterministic, timer-driven and boring on purpose.

Automate the mechanical part, keep the judgment out of the model's hands. That is the same line I drew building ViralVault, where the publishing pipeline still needs editorial judgment.

Every DateSafe feature that keeps you safe is free, for everyone, forever: sessions, check-ins, the panic slider, the duress PIN, your trusted circle, one-tap 911, and full data export and delete.

I have a note from an early planning doc where one row proposed charging for panic alerts. That is not a business model. That is a ransom note with better fonts. The person most likely to need a panic button is often the person least able to pay for one.

DateSafe Plus adds convenience around the safe core, never the core itself: a live watch link so your circle can follow along on a private page, an unlimited circle, the safe word by SMS, a private journal, the AI helpers, and custom check-in cadences.

Billing on a phone is a different problem from billing on the web. Everything I learned wiring Stripe into Codelit applies to a browser checkout, and on iOS the store's rules make a lot of those decisions for you, which is how RevenueCat ended up in the diagram at all.

The review notes explain that split plainly. More importantly, it is the product constraint I want to keep: convenience can be paid; the core alerting controls should not depend on an upgrade.

Shipping DateSafe on Apple's rails left marks. My annual product is named datesafe_plus_2_annual, with a permanent "2" in it, because I had already created and deleted an earlier identifier. Product identifiers need to be treated as permanent choices, not disposable labels.

The iOS implementation includes a home-screen widget and a Live Activity countdown. The public release is available on iPhone; Android remains on the roadmap. An implemented Android notification path should not be confused with a shipped Android app.

DateSafe is available on iPhone, with core safety features free and a paid convenience tier. The product supports private coordination through check-ins, shared plans, and alerting. Safety work is not something I consider finished.

Recent additions include the live watch link, private Person History, and a getting-home mode. The test for each is whether it helps the user coordinate without quietly widening who can see their information.

There is also a private little counter on the home screen: "dates protected." It never leaves your phone and it is not analytics. It is a record of completed sessions, not proof that the app prevented harm.

If you build something in this shape, copy the ordering rather than the stack. The missing rating database, the durable duress delivery path, and the Postgres timer follow one rule: define the user's boundary first, then make the architecture respect it.

DateSafe helps you plan and coordinate with people you trust. It is not an emergency service. If you are in immediate danger, contact local emergency services; in the US, call 911.

Questions people actually ask

What is a duress PIN?
A duress PIN is a second passcode that looks like your normal one but quietly signals for help. In DateSafe you set two PINs. The real one ends a safety session normally, and the duress one ends it too, looking identical on screen, while silently telling your trusted circle that something is wrong. To anyone watching over your shoulder, you typed a code and closed an app.
Why does DateSafe not let users rate their dates?
Because a searchable database of unverified claims about named people, with no due process, is not a safety tool. It is a weapon waiting for its first misuse. DateSafe was specified on day one as private planning, trusted-circle sharing, check-ins, and user-controlled escalation, and never as a public record of anyone. The reframe underneath it is that dating safety is coordination with people who love you, not information about strangers.
Can you use a Postgres table instead of a job queue for scheduled alerts?
Yes, and for DateSafe it was the better choice. Scheduled escalations are rows in a table with a due time and an action. A worker wakes every twenty seconds and selects the rows that are due. Row claiming prevents workers from processing the same schedule concurrently, but it does not guarantee exactly-once SMS delivery. Session rechecks, idempotency, durable notification records, and delivery evidence address separate failure cases.
Is DateSafe free?
Core safety features are free, including sessions, check-ins, the panic slider, the duress PIN, and the trusted circle. DateSafe Plus adds convenience features around that core. Check the current in-app offer for pricing and entitlements; an app is not an emergency dispatch service or a guarantee of safety.
Does DateSafe use AI to decide whether someone is safe?
No. Explicit user actions and timers drive the escalation path, not a model's assessment of a person. The architecture separates non-emergency AI helpers from alerting. Tests with the AI worker unavailable check that boundary, not the reliability of external delivery. The model handles non-emergency helpers behind a swappable provider port, a safety-plan drafter and a message red-flag coach. It never decides whether a person is safe and it never triggers an emergency.