vineroute// field manual
Rider
Driver
Dispatch
Systems
Systems/Authentication
01 / 06

Authentication

Email OTP for riders, password for admins

JWTOTPHono

Overview

We considered a managed auth provider, then shipped our own — the surface area is tiny. The users table stores email, passwordHash (optional), and a role enum (passenger / driver / admin). The otpTokens table is append-only and holds short-lived codes scoped to an email. Every request to a protected endpoint reads the JWT from the Authorization header and resolves it to a user record.

How it works

1

`POST /auth/otp/request` validates the email with Zod, generates a six-digit code, hashes it with bcrypt, writes a row to otpTokens with expiresAt set to now plus 10m, and returns ok and expiresAt plus a devCode if NODE_ENV is not production.

2

`POST /auth/otp/verify` looks up the most recent non-expired token for the email, compares the submitted code via bcrypt, and on match issues a JWT signed with JWT_SECRET for 30 days.

3

`POST /auth/password` is the admin-only fallback — it bcrypt-compares the submitted password against users.passwordHash and issues the same JWT shape.

4

Hono middleware `requireAuth` reads the Bearer token, verifies it with jose, and attaches userId and role to the context. Every protected route uses it.

5

Role-gated middleware (requireDriver, requireAdmin) check the context role after requireAuth and return 403 if mismatched. The middleware composes — `app.use('/admin/*', requireAuth, requireAdmin)`.

6

Token persistence on mobile: expo-secure-store stores the token. On web, localStorage holds it.

Key decisions

Roll our own over a managed provider

Clerk and Auth0 are great for OAuth-heavy apps. Vineroute needs email OTP and a password for admins — two endpoints, a JWT issuer, a middleware. The managed-provider overhead (extra SDK, runtime cost, vendor lock-in) didn't justify itself. The whole auth module is 200 lines.

OTP codes hashed at rest

We bcrypt the OTP before storing it, so the database never holds a usable code. Combined with the 10-minute expiry, a DB compromise can't replay codes. The verify step compares the submitted plaintext against the hash — same idea as passwords.

JWT, not session cookies

We support two clients — mobile and web. Mobile needs a token-style auth because it isn't a browser; sessions assume cookies. Issuing a JWT and Bearer-ing it on every request keeps the auth model identical across surfaces.

NextDatabase