Authentication & Authorization
Overview
Evergrn uses stateless JWT authentication. There is no session store โ every request is self-contained. The server verifies the token's signature on each request and reads the embedded id and role claim to identify the caller.
There are five distinct token types:
| Role / Type | Who | Token expiry |
|---|---|---|
user |
Customer accounts | 7 days |
provider |
Professional accounts | 7 days |
admin |
GodMode only [GODMODE] | 24 hours |
staff (type field) |
Internal support staff | 12 hours |
| (staff impersonation) | Staff spoofing a user/provider during elevation | 15 minutes |
| (admin impersonation) | Admin spoofing a user/provider [GODMODE] | 2 hours |
Token Format
Tokens are signed JWTs using HS256 (HMAC-SHA256) with JWT_SECRET from .env.
Payload structure:
{ "id": "<cuid>", "role": "user | provider | admin", "iat": ..., "exp": ... }
The id field refers to User.id, Provider.id, or Admin.id depending on role.
Signing & Verification
Signing (at login/register, in src/routes/auth.js):
jwt.sign({ id: user.id, role: 'user' }, process.env.JWT_SECRET, { expiresIn: '7d' })
Verification (in src/middleware/auth.js):
const payload = jwt.verify(token, process.env.JWT_SECRET)
req.user = { id: payload.id, role: payload.role }
The middleware extracts the token from the Authorization: Bearer <token> header. It returns 401 if missing, expired, or tampered. The decoded payload is attached to req.user for downstream route handlers.
Password Hashing
All passwords are hashed with bcryptjs at 12 salt rounds before storage. This applies to User, Provider, and Admin accounts.
const hashed = await bcrypt.hash(password, 12)
// stored in DB
const match = await bcrypt.compare(plaintext, hashed)
// verified at login
12 rounds produces ~250ms of hashing time per operation on modern hardware โ high enough to make brute-force impractical, low enough to not impact UX.
Customer Auth Flow
Registration โ POST /auth/register
Client โ POST /auth/register { email, password, name, address, zip }
โ
Server: hash password โ prisma.user.create()
โ
Response: { token, user: { id, email, name } }
Login โ POST /auth/login
Client โ POST /auth/login { email, password }
โ
Server: findUnique(email) โ bcrypt.compare() โ sign JWT
โ
Response: { token, user: { id, email, name } }
On error (email not found, wrong password): returns 401 with { error: 'Invalid credentials' }. The same error message is returned for both cases to prevent email enumeration.
Provider Auth Flow
Identical to customer auth but uses Provider model and role 'provider'.
Registration โ POST /auth/provider/register
Client โ POST /auth/provider/register { email, password, name, phone, serviceZips[], services[] }
โ
Server: hash password โ prisma.provider.create()
โ
Response: { token, provider: { id, email, name } }
Login โ POST /auth/provider/login
Client โ POST /auth/provider/login { email, password }
โ
Response: { token, provider: { id, email, name } }
Token Storage
Web (React)
Tokens are stored in localStorage under the key auth_token. The user object is stored separately under auth_user.
// AuthContext.jsx
login(token, user) {
localStorage.setItem('auth_token', token)
localStorage.setItem('auth_user', JSON.stringify(user))
}
logout() {
localStorage.removeItem('auth_token')
localStorage.removeItem('auth_user')
}
The context also decodes the JWT client-side (reading the role claim from token.split('.')[1]) to determine which dashboard to render.
Security note: localStorage is vulnerable to XSS. See vulnerabilities.md for mitigation recommendations.
Mobile (React Native)
Tokens are stored in Expo SecureStore, which uses the device's secure enclave (iOS Keychain / Android Keystore). This is significantly more secure than localStorage.
// AuthContext.js
await SecureStore.setItemAsync('auth_token', token)
const token = await SecureStore.getItemAsync('auth_token')
await SecureStore.deleteItemAsync('auth_token')
Authorization on Routes
Most routes call authenticate middleware first:
router.get('/jobs', authenticate, async (req, res) => {
// req.user = { id, role }
})
Role checks inside route handlers follow this pattern:
if (req.user.role !== 'provider') return res.status(403).json({ error: 'Forbidden' })
Access control matrix
| Endpoint | user | provider | admin | unauth |
|---|---|---|---|---|
| POST /auth/register | โ | โ | โ | open |
| POST /auth/login | โ | โ | โ | open |
| GET /jobs | โ | โ | โ | 401 |
| POST /jobs | โ only | โ | โ | 401 |
| GET /jobs/:id | owner or matching provider | if PENDING/QUOTED | โ | 401 |
| GET /jobs/reportable | โ only | โ | โ | 401 |
| POST /jobs/:id/report | owner only | โ | โ | 401 |
| POST /quotes | โ | โ only | โ | 401 |
| POST /quotes/:id/accept | owner only | โ | โ | 401 |
| GET /providers/me/* | โ | โ only | โ | 401 |
| POST /providers/me/push-token | โ | โ only | โ | 401 |
| GET /providers/me/notification | โ | โ only | โ | 401 |
| POST /reviews | โ only | โ | โ | 401 |
| GET /payments/method | โ only | โ | โ | 401 |
| GET /services/available | โ only | โ | โ | 401 |
| GET /users/me | โ only | โ | โ | 401 |
| GET/POST /users/me/addresses | โ only | โ | โ | 401 |
| PATCH /users/me/addresses/:id/primary | โ only | โ | โ | 401 |
| DELETE /users/me/addresses/:id | โ only | โ | โ | 401 |
| POST /users/me/push-token | โ only | โ | โ | 401 |
| GET /users/me/notification | โ only | โ | โ | 401 |
| /admin/* | โ | โ | โ only | 401 |
Staff Auth Flow
Overview
Staff accounts are separate from customer and provider accounts. They have their own Staff model, their own login endpoint, and JWT tokens with type: 'staff' in the payload. Four roles are supported: customer_support, professional_support, technical_support, internal_audit.
Login โ POST /staff/login
Client โ POST /staff/login { email, password }
โ
Server: prisma.staff.findUnique() โ bcrypt.compare() โ sign JWT { id, role, type: 'staff', exp: 12h }
โ
Response: { token, staff: { id, name, email, role } }
Token Verification
Staff middleware (requireStaff) verifies both the JWT signature and the type: 'staff' claim. A regular user or provider token cannot access staff routes even with a valid signature.
// requireStaff(roles = []) โ used inline in src/routes/staff.js
const payload = jwt.verify(header.replace('Bearer ', ''), process.env.JWT_SECRET)
if (payload.type !== 'staff') return res.status(403).json({ error: 'Forbidden' })
if (roles.length && !roles.includes(payload.role)) return res.status(403).json({ error: 'Forbidden' })
req.staff = payload
A separate requireAdmin function checks payload.role === 'admin' (the GodMode admin token, not a staff token) for staff management endpoints (GET /staff/list, POST /staff/create).
Elevation / Impersonation Flow
When a staff member needs to access a customer or provider account to investigate an issue, they go through an elevation flow:
1. Staff submits POST /staff/elevation { targetId, targetRole, reason }
โ
2. Email sent to admin with Approve / Deny links
โ
3. Admin clicks Approve โ ElevationRequest.status โ APPROVED
โ
4. Staff calls POST /staff/impersonate { elevationId }
Server issues: jwt.sign({ id: target.id, role: target.role, elevationId }, exp: '15m')
ElevationRequest.status โ ACTIVE
โ
5. Staff uses the 15-minute token as a normal Authorization header on any endpoint
Every request logged to AuditEvent by the global auditLog middleware
โ
6. Token expires (15 min) โ auto-deactivated on next staff portal refresh
Staff can also manually deactivate: PATCH /staff/elevation/:id/deactivate
The impersonation token contains elevationId so the audit middleware can identify which elevation session each request belongs to. All API calls made with this token are permanently recorded in AuditEvent (elevationId, method, path, body).
Access Control โ Staff Routes
| Endpoint | customer_support | professional_support | technical_support | internal_audit | admin (GODMODE) |
|---|---|---|---|---|---|
| GET /staff/customers | โ | โ | โ | โ | โ |
| GET /staff/customers/:id | โ | โ | โ | โ | โ |
| GET /staff/providers | โ | โ | โ | โ | โ |
| GET /staff/providers/:id | โ | โ | โ | โ | โ |
| POST /staff/elevation | โ | โ | โ | โ | โ |
| POST /staff/impersonate | โ | โ | โ | โ | โ |
| GET /staff/tickets | โ | โ | โ | โ | โ |
| PATCH /staff/tickets/:id | โ | โ | โ | โ | โ |
| GET /staff/audit/sessions | โ | โ | โ | โ | โ |
| GET /staff/list | โ | โ | โ | โ | โ |
| POST /staff/create | โ | โ | โ | โ | โ |
| POST /staff/impersonate-staff | โ | โ | โ | โ | โ |
| GET/PATCH elevation (own) | โ | โ | โ | โ | โ |
GodMode / Impersonation Flow [GODMODE]
This flow is dev-only and must be removed before production. See
vulnerabilities.md.
Admin Login โ POST /admin/login
Rate-limited to 10 attempts per 15 minutes per IP.
Client โ POST /admin/login { email, password }
Server: find Admin record โ bcrypt.compare() โ sign JWT { role: 'admin', exp: 24h }
Response: { token }
The token is stored in localStorage under godmode_admin_token (separate from normal auth).
Impersonation โ POST /admin/impersonate
Admin client โ POST /admin/impersonate { targetId, targetRole }
Authorization: Bearer <admin_token>
โ
Server: find User/Provider โ sign JWT { id: target.id, role: target.role, exp: 2h }
Response: { token, identity: { id, email, name, role } }
The admin's original token is preserved in localStorage. The impersonation token is loaded into AuthContext as a normal login. The ImpersonationBanner component detects the presence of godmode_admin_token alongside a logged-in user to show the purple exit bar.
Exit Impersonation
Exit button โ logout() (clears AuthContext + auth_token) โ navigate('/godmode')
The godmode_admin_token persists through impersonation and is cleared only on admin sign-out.
Password Reset Flow
Security design
- Token generation:
crypto.randomBytes(32).toString('hex')โ 256 bits of entropy, 64-character hex string - Token storage: SHA-256 hash of the raw token is stored in the
PasswordResetTokentable; the raw token only ever exists in the email link. If the DB is compromised, stored hashes are useless without the original token. - Token lifetime: 1 hour (
expiresAt = Date.now() + 3_600_000) - Single-use: token record is deleted immediately after a successful password reset
- Reuse prevention: requesting a new reset deletes any outstanding token for that email+role before creating a new one
- Email enumeration protection:
POST /auth/forgot-passwordalways returns200with the same message regardless of whether the email exists - Rate limiting: 5 requests per 15 minutes per IP on the forgot-password endpoint
Database model
PasswordResetToken {
id cuid PK
tokenHash String @unique โ SHA-256(rawToken)
email String โ account email
role String โ 'user' | 'provider'
expiresAt DateTime
createdAt DateTime
@@index([email, role])
}
Full flow
1. User clicks "Forgot password?" on login screen
โ
2. POST /auth/forgot-password { email, role }
Server: look up account โ delete old tokens โ generate rawToken โ
store SHA256(rawToken) + expiry โ send email with link
โ
3. Email arrives: "Reset password" button links to
{APP_URL}/reset-password?token={rawToken}
โ
4. User clicks link โ ResetPassword page loads, reads token from URL query param
(Mobile: user opens link in browser, which opens the web reset page)
โ
5. POST /auth/reset-password { token, newPassword }
Server: SHA256(token) โ look up record โ check expiry โ
bcrypt.hash(newPassword, 12) โ update User or Provider password โ
delete PasswordResetToken โ return { message, role }
โ
6. Web: auto-redirects to /login or /provider/login after 3 seconds
Mobile: user sees success state, navigates back to sign in
Required environment variables
APP_URL = http://localhost:5173 (dev) | https://yourdomain.com (prod)
SMTP_HOST = smtp.gmail.com
SMTP_PORT = 587
SMTP_USER = your@email.com
SMTP_PASS = your-app-password-or-smtp-key
Compatible SMTP providers: Gmail (app password), SendGrid, Mailgun, Resend, Postmark, AWS SES.
New files
src/config/mailer.jsโ nodemailer transport +sendPasswordReset()functionclient/src/pages/ForgotPassword.jsxโ web email entry form; reads?role=from URLclient/src/pages/ResetPassword.jsxโ web new-password form; reads?token=from URLmobile/src/screens/auth/ForgotPasswordScreen.jsโ mobile email entry; receivesrolevia nav params
Stripe Payment Auth
Card save and payment flows are separate from user auth but use the same JWT to identify the customer:
Save card:
POST /payments/setup-intentreturns a StripeclientSecret. The web frontend uses Stripe Elements to collect card details and confirm the SetupIntent client-side. ThenPOST /payments/payment-methodtells the backend which PaymentMethod ID was confirmed.Payment authorization: When a customer accepts a quote, the backend creates a
PaymentIntentwithcapture_method: 'manual'andoff_session: true, charging the saved card immediately but only authorizing (not capturing) the funds.Capture: When the provider marks the job COMPLETED (via
PATCH /jobs/:id/statusorPOST /jobs/:id/complete), the backend callsstripe.paymentIntents.capture(id).Webhooks:
POST /webhookshandles Stripe events (payment_intent.succeeded,payment_intent.payment_failed, etc.) to keepPayment.statusin sync. The raw request body is preserved for Stripe's signature verification usingSTRIPE_WEBHOOK_SECRET.