Skip to content
PharmaFlow Knowledgebase
Esc
↑↓navigate↵open⌘Jpreview
On this page

Authentication and Users

Log in, understand the HS256 JWT claims, enforce role checks, and manage users through the admin endpoints.

You authenticate every request with a bearer token from POST /auth/login. The token logic lives in apps/server/src/core/auth/token.ts, the middleware and role checks live in apps/server/src/core/auth/middleware.ts, and the login routes live in apps/server/src/features/auth/route.ts.

How login works

You send an email and password to the public login endpoint. It is rate limited to 5 attempts per 60 seconds per client by the loginRateLimit middleware in apps/server/src/core/auth/middleware.ts, backed by the per address counters in apps/server/src/core/auth/login-attempts.ts. A failed attempt stays in the window while a successful one removes its own entry.

curl -X POST http://localhost:8000/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"admin@demo.pharmaflow","password":"Demo@1234"}'

You get back access_token, token_type, user_id, role, branch_id, branch_name, and full_name. Every login writes a LOGIN row to the audit log through apps/server/src/features/auth/service.ts with the caller IP, and updates last_login_at on the user.

The JWT shape

Tokens are HS256 JWTs signed with JWT_SECRET, which must be at least 32 characters. The default lifetime is 480 minutes, configurable with JWT_EXPIRE_MINUTES and enforced in apps/server/src/core/auth/token.ts. The token carries an org_id claim that selects the tenant, and authMiddleware never trusts an org from a header.

{
	"sub": "user-uuid",
	"role": "admin",
	"branch_id": "branch-uuid",
	"email": "admin@demo.pharmaflow",
	"org_id": "default"
}

You send the token on every protected call:

curl http://localhost:8000/auth/me \
  -H "Authorization: Bearer $TOKEN"

How authMiddleware protects you

authMiddleware in apps/server/src/core/auth/middleware.ts verifies the token, then re-reads the user row on every request. If the account is missing or is_active is not true, you get a 401 even with a valid token. It also overwrites the token role and branch_id with the fresh database values, so a demoted user loses access immediately, and it applies the tenant search path from the token org_id through apps/server/src/core/database/tenancy.ts.

How requireRoles works

requireRoles(...allowed) in apps/server/src/core/auth/middleware.ts runs after authMiddleware. The admin role always passes, every other role must be in the allowed list or you get a 403. requireAdmin is requireRoles() with an empty list, so only admins pass.

userRoutes.get("/", requireAdmin, async (context) => { ... });
syncRoutes.post("/push", requireRoles("admin", "branch_manager", "pharmacist", "cashier"), async (context) => { ... });

Managing users

All user routes live in apps/server/src/features/users/route.ts behind authMiddleware, and the business rules live in apps/server/src/features/users/service.ts. Admins list everyone with role and active filters, and the list defaults to active users only.

curl "http://localhost:8000/users?page=1&page_size=20&role=pharmacist&is_active=true" \
  -H "Authorization: Bearer $TOKEN"

You fetch one user when you are that user or an admin. GET /users/:userId returns 403 otherwise and 404 for unknown ids.

Creating and updating users

Only admins create users with POST /users. The role must be admin or pharmacist, the branch must exist and be active, the email must be unique and valid, and the password must be at least 8 characters. Creation emits a users sync change and logs a CREATE audit row, both in apps/server/src/features/users/service.ts.

curl -X POST http://localhost:8000/users \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"email":"new@demo.pharmaflow","password":"Secret123","full_name":"New Pharmacist","role":"pharmacist","branch_id":"BRANCH_ID"}'

You update with PUT /users/:userId under a field allowlist. You may change your own full_name and phone only, while admins may also change role, branch_id, and is_active. Passwords never go through this endpoint. Use POST /auth/change-password for your own password with current_password plus a new_password of 8 or more characters, and POST /users/:userId/reset-password for the admin reset with matching new_password and confirm_password.

Next steps

Read how branches scope your data in /platform/branches, query who changed what in /platform/audit-log, and walk the full counter flow in /guides/selling-end-to-end.

Last updated on October 20, 2026