Trust & security: the posture in practice
How the server decides every protected action at request time, and how every secret rests as a hash
The screen of Folake's phone gives out on a Tuesday, mid-restock. The replacement arrives the next morning, and she signs in with her phone number and her password. The server hashes what she typed, matches it against the stored fingerprint, confirms the account is active, and hands the new phone a fresh pair of tokens. Then every record arrives from the server: the stock counts, the payroll history, the team. The pharmacy lives on the server, and sign-in is how a phone earns the right to ask for it.
Server-side authorization #
Every protected request is decided on the server at the moment it arrives. The server validates the bearer token, re-reads the user row, and confirms the account is active and verified. A business-scoped request then resolves the caller's membership in that business and tests the action's capability against the membership's effective set: the stored grants for a team member, all 18 capabilities for the owner. The team doc carries the vocabulary and the grant rules.
The check reads live rows on every request, so a change bites at once. When the owner deactivates a member, the very next request under that membership fails business-context resolution exactly as an absent membership would: 404 business_context_not_found. The member's tokens stay technically live; the answer stays 404, because every request re-asks the database.
The app renders from the same capability facts, and the team doc carries its four render states: an owner-only action is omitted, a blocked action explains itself, a locked section keeps its place, and the tab bar itself follows the capability list. All four are presentation. The server runs its check whatever the screen showed, so a stale screen, a shared link, and a crafted request all land on the same check.
Some reads split by capability inside one response, and the idiom is null, distinct from empty. Product detail returns recent_movements as null to a reader outside view_stock_history, and the payroll run list withholds its run contents the same way, so a locked history reads as locked and an empty one reads as empty.
The inventory doc shows the surface of this: Ngozi records a stock-out, and the Recent movements block on the same product renders a permission explanation in place of the feed. Security-side, that screen is three server decisions. Her create_stock_movements grant admitted the write. Her view_inventory grant admitted the product read. And the response carried recent_movements as null, because view_stock_history sits outside her set, so her app rendered the locked section off the server's answer alone, with zero client re-check; a direct request for the movement feed under her membership answers 403 forbidden. The screen and the server agree because the screen renders what the server decided.
Refusals answer in three codes. A missing or expired token answers 401. An authenticated request for an action outside the caller's grants answers 403. And a resource id from another business answers 404, whatever the id names, so existence stays private across businesses. Authorization also runs first in the write pipeline, before the idempotency guard, the repository writes, and the audit event, so a refused write changes zero rows and emits zero events; the audit doc carries the pipeline.
The check, per request
- The bearer token is a signed JWT presented in the
Authorizationheader;401marks an authentication failure,403an authorization failure. - The user check reads
statusandverified_atfrom the live row, per request. - Membership resolution treats a disabled membership and a missing one identically:
404 business_context_not_found. - The capability test is flat set membership against the resolved effective set; Job Titles carry zero authorization meaning.
- The app's tab shell renders from the capability list, and a hidden tab also blocks navigation to its routes, so most unauthorized requests end on the phone; the server decides every request that does leave it.
Sign-in and verification (verification_challenges) #
An account signs in with a verified login identifier, an email or a phone number, plus a password of at least 8 characters; the settings doc carries the line between login identifiers and contact data. The server keeps the password as a salted scrypt hash, scrypt being a deliberately slow and memory-hungry algorithm built to make bulk guessing expensive, and it checks a sign-in by hashing the attempt and comparing fingerprints in constant time.
Sign-in errors answer in one shape. A wrong password, an unknown identifier, and an ineligible account all return the same 401 invalid_login_credentials, and the app shows one form-level message: "Sorry, your phone number or password is incorrect". The answer reveals zero about which half was wrong and zero about whether the number holds an account at all. Registration is the one surface that speaks plainly: an identifier that already carries an account answers a typed duplicate-account conflict, and the path from there is sign-in.
A new account activates through a verification challenge. Registration mints a 5-digit code, stores its keyed hash, and sends the raw code over the chosen channel; the raw value exists in server memory long enough to send and to check, and in the message that lands on the phone. The challenge expires in 10 minutes. The fifth wrong attempt exhausts it, and further tries answer 429 until a fresh code is requested. Resend opens 90 seconds after each send, and the app's countdown runs off the server's resend_available_at, so the button unlocks exactly when the server will accept the request.
Delivery fails closed. A send that fails rolls the whole challenge back, zero pending challenge and zero cooldown, and answers 503, so the screen offers resend at once. An environment with zero configured delivery provider refuses the send the same way, and a send that succeeds is the only path to a stored challenge.
A correct code flips the account to active and issues the first token pair in the same transaction, so the person leaves verification with a durable session.
Challenge detail
- A challenge carries a status:
pending,verified,expired,superseded, orexhausted. A fresh code supersedes prior pending challenges. - Errors are typed:
409 verification_code_expired,429 verification_attempt_limit_reached,400 invalid_verification_code,409 verification_resend_cooldown,503 verification_delivery_failed. - Registration and resend responses return the destination masked, plus
expires_atandresend_available_at, so the app drives its timer with zero raw identifier in the body. - Email codes send through Resend; SMS codes send through Termii on the
dndchannel, so codes reach numbers with Do-Not-Disturb active. - The numbers are deployment settings; 5 digits, 10 minutes, 5 attempts, and 90 seconds are the defaults.
- Local development uses a fixed code and the server logs a security warning at startup; staging and production generate random codes, and a process with zero configured environment resolves to production behavior.
Sessions (refresh_tokens) #
A session is two tokens. The access token is a signed JWT the phone presents on every request; it lives 7 days by default, and it names the user alone, so business access resolves from memberships on each request instead of riding inside the token. The refresh token is an opaque random secret that lives 30 days by default; the server keeps its keyed hash, returns the raw value exactly once, and reads it again only to mint the next pair.
Refresh rotates the token. Presenting it revokes it, links it to a freshly minted replacement, and returns the new pair, in one transaction. A 60-second grace window covers a phone that lost the response mid-rotation: inside it, the just-rotated token is accepted again provided its replacement went unused, and the orphaned replacement is revoked before the new pair issues, so at most one usable descendant exists at any moment. A used replacement marks the replay as reuse and it stays rejected, and a token revoked by logout sits outside the grace entirely.
Logout revokes the presented refresh token and replays safely. The access token runs to its own expiry; what ends access sooner is the per-request check itself: a deactivated account or a disabled membership fails on its very next request, whatever tokens the phone still holds.
Token wire detail
- The JWT algorithm is pinned to HS256; a token naming any other algorithm or type fails before signature comparison. Issuer and audience are validated, and a token stamped more than 60 seconds in the future fails.
- The refresh secret is 48 random bytes; unknown, expired, and revoked values answer
401 invalid_refresh_token. - The auth surface is six routes:
POST /auth/register,POST /auth/verify,POST /auth/resend-verification,POST /auth/login,POST /auth/refresh,POST /auth/logout. - Lifetimes and the grace window are deployment settings; 7 days, 30 days, and 60 seconds are the defaults.
Secrets at rest #
One rule covers every secret the server keeps: the database stores fingerprints, and each raw value exists in exactly one other place.
| Secret | At rest | Where the raw value exists |
|---|---|---|
| Password | Salted scrypt hash | With the person |
| Verification code | Keyed hash | In the delivered message |
| Refresh token | Keyed hash | In the phone's keystore |
| Invite link token | Keyed hash | In the create and resend responses the owner shares |
| Invite code | Keyed hash | In the same two responses |
A keyed hash is an HMAC-SHA256 fingerprint computed under the server's secret key. It verifies a presented value and yields the original to nobody, and computing one needs the key, so the stored fingerprints prove useful to the server alone. The team doc carries the invitation credentials this table summarizes.
That key is the signing secret, and its configuration is a contract: required in every runtime, 32 characters minimum, zero committed default. An unconfigured process fails during settings validation instead of starting with a publicly known key, and staging and production each carry a distinct value.
Every secret comparison runs in constant time: the password check, the code check, and the token signature alike.
Stored forms
- A stored password reads
scrypt$16384$8$1$<salt>$<hash>: the cost parameters (n 16384, r 8, p 1), a 16-byte random salt, and a 64-byte derived key ride together, so verification reads its parameters from the stored value. - The verification-code hash binds the challenge id to the code, so a code proves itself against its own challenge alone.
- Refresh-token and invitation-credential hashes sit in unique columns, so a stored fingerprint resolves to at most one row.
The phone (expo-secure-store) #
The phone holds the two tokens differently. The access token lives in app memory alone and ends with the app process. The refresh token rests in the platform keystore, the hardware-backed store behind the device's own lock, marked readable on this device alone and only while the device is unlocked. A device whose keystore is unavailable fails the save loudly; the app ships zero plain-text fallback.
App start restores the session from the keystore, and the stored session is cleared in exactly two cases: the server definitively rejects it, or it has expired by its own timestamp. A dead network, a timeout, and a 5xx all keep it, so a bad connection signs nobody out. When rotation succeeds, the replacement persists before any other work, so an app killed mid-rotation finds the rotated token on the next start.
Every request leaves the phone with Cache-Control: no-store, which keeps payroll and employee payloads out of the shared on-disk HTTP cache, and the bearer token rides the Authorization header alone.
Signing out deletes the keystore entry and resets the local database in one motion, so queued offline writes and their payloads end with the session. The offline-sync doc carries the queue; the settings doc carries the dialog that says so.
Device storage detail
- The keystore entry is one JSON value: the refresh token, its expiry, and the user id. An entry that fails to parse clears the session.
- Restore settles into one of three outcomes: restored; signed out, for zero stored session, local expiry, or a definitive
401/403; or restore-failed, for transient errors, with the stored token kept. - The replay worker re-reads the session before every queued operation, so sign-out halts it mid-batch.
- The local database is SQLite holding the sync outbox; a queued write's payload rests there between save and replay, and the sign-out reset removes every row.
What Tomero stores, and what it never stores #
An account is a small record: the full name, the login email and phone number, the contact phone number, the password hash, the account status, and the moment and version of the accepted Privacy Policy and Terms of Service. Business records, the stock, the payroll, the team, live under the business, and money in them rests as integer minor units, kobo for naira; the product core carries the model.
Identifiers are masked wherever the reader's claim to them is thin. An invitation preview shows the invited address as a***@example.com or +234********123, enough to recognize yourself and too little to harvest. Audit metadata masks invited identifiers the same way, an actor with zero display name falls back to a masked email or phone, and the Home preview names entities alone; the audit doc carries the copy rules.
- Raw passwords, verification codes, refresh tokens, and invitation credentials never rest in the database; each is stored as a hash alone.
- Tokens, codes, and invited identifiers never enter URLs or route parameters, on the server or on the phone.
- Secrets, OTP values, password hashes, and raw payroll internals never enter logs, analytics, or audit metadata, and audit reads pass a per-type allowlist besides.
- Error responses never carry stack traces or SQL detail; the typed failure code and a request id travel, and the rest stays on the server.
- The phone never writes tokens or codes to its local database or general storage; the keystore entry is the only secret at rest on the device.
Exactly two providers ever see a verification code: Resend carries email codes, Termii carries SMS codes. Invitation delivery uses zero provider at all: the owner shares the link and code personally, and Tomero keeps their hashes alone.
Launch boundaries #
The model is built so later workflows arrive as additions. The boundaries today:
- OTP serves account verification alone; sign-in takes the identifier and the password.
- Password reset is a later slice; the Forgot password control on the sign-in screen ships inactive.
- Changing a verified login email or phone is a deferred security workflow; the Security row in Settings holds its place under a Coming soon pill.
- The app adds zero lock of its own; the device's lock and the keystore rules carry device protection at launch.