How Bohari IMS protects your company's data: authentication, encryption, tenant isolation, rate limiting, and the audit trail.
This guide describes the security controls built into the platform: how users sign in, how sensitive values are encrypted, how each company's data is kept separate, how files are served, and what gets recorded for compliance. Everything here reflects how the platform actually behaves, so when your own security or compliance team asks, you can answer with specifics.
Authentication
Bohari IMS uses JSON Web Tokens (JWT) for API access. After you sign in, the platform issues a signed token that your browser or the mobile app sends with each request in the Authorization: Bearer ... header. Expired tokens are always rejected: the server does not ignore expiry.
There are several token types, and each one can only do its own job:
| Token | Purpose | Lifetime |
|---|---|---|
| Access token (web) | Normal API calls from the browser | 1 hour (JWT_WEB_EXPIRATION) |
| Access token (mobile) | Normal API calls from the mobile app | 24 hours (JWT_EXPIRATION) |
| Refresh token | Silently renew a web session | 7 days (JWT_REFRESH_EXPIRATION) |
| 2FA-pending token | Held briefly between password and 2FA code | 5 minutes |
| 2FA-setup token | Forces enrollment when 2FA is mandatory | 15 minutes |
Only full access tokens and the 2FA-setup token can authenticate an API request. The 2FA-pending and refresh tokens are single-purpose and get rejected on normal endpoints. The 2FA-setup token is restricted even further: it only works on the profile and 2FA-enrollment routes, so a user who hasn't enrolled yet can't reach anything else.
Web sessions and the refresh cookie
Browser sessions use a short-lived access token plus a long-lived refresh token stored in an httpOnly cookie. The cookie is named __session, is scoped to the /api/auth path (so it's never sent with normal API calls), uses SameSite=Lax, and is marked Secure in production. It's rotated on every refresh, so a stolen cookie has a bounded life. "Remember me" controls whether the cookie persists across browser restarts.
Mobile clients use the longer-lived bearer token instead of cookies.
Sign-in methods
- Email and password (local).
- Google OAuth.
- Sign in with Apple (native iOS), which can also be linked to an existing account from profile settings.
Google and Apple sign-in only work for accounts that already exist. There's no self-service account creation through a social provider; an administrator provisions the account first.
Password storage
Passwords are hashed with bcrypt. The work factor defaults to 12 rounds (BCRYPT_ROUNDS). Plaintext passwords are never stored. In production, the platform refuses to start unless JWT_SECRET is set to a strong value (at least 32 characters and not the development placeholder).
Password reset
Reset requests return the same response whether or not the email exists, which stops attackers from discovering valid accounts. The reset link carries a random token; only its SHA-256 hash is stored, and the link expires after 30 minutes.
Two-Factor Authentication (2FA)
Bohari IMS supports time-based one-time passwords (TOTP), the kind generated by apps like Google Authenticator or Authy.
- Enrolling generates a secret and a QR code. 2FA isn't active until you enter a valid code to confirm it.
- Codes are accepted within roughly 30 seconds either side of the current step, to allow for clock drift.
- A code that's already been used in its time window is rejected, so the same six digits can't be replayed.
- On first enrollment you receive 10 single-use backup codes. Only their SHA-256 hashes are stored, and each code is removed once used.
- With 2FA on, a correct password returns a short-lived 2FA-pending token, not a full session. You then submit a TOTP code (six digits) or a backup code to finish signing in.
The stored TOTP secret and backup codes are encrypted at rest on top of the hashing above (see below).
Platform-enforced 2FA
A platform administrator can require 2FA for everyone. When that setting is on and a user hasn't enrolled, signing in returns a setup-scoped token that only reaches the enrollment screens. The user has to finish setting up 2FA before they can use the rest of the platform. Refreshing a web session re-checks this too, so a user who newly falls under the requirement is sent back to enroll.
Encryption
In transit
Production traffic runs over HTTPS/TLS. The refresh cookie is Secure in production, so it's only ever sent over an encrypted connection.
At rest
Sensitive stored values are encrypted with AES-256-GCM, an authenticated cipher (it detects tampering as well as keeping data confidential). The encryption key is derived from an environment secret (ENCRYPTION_KEY / TOKEN_ENCRYPTION_KEY); in production the platform requires a real key to be configured rather than the development default.
What's encrypted at rest today:
- 2FA secrets and backup codes on the user record.
- Third-party integration credentials: OAuth client secrets and access/refresh tokens for connected integrations, such as your accounting connection.
- Webhook signing secrets, so the value used to sign outbound webhook payloads is never stored in the clear.
These use a transparent encrypt-on-write, decrypt-on-read transformer, so the raw values are never written to the database in the clear. They're decrypted only when the application reads the record to serve an authorised request.
Day-to-day operational data (items, recipes, stock movements, invoices, supplier records) relies on tenant isolation and permissions rather than field-level encryption. If you need to know the handling of a specific field, ask your administrator.
Protecting personal data in AI features
When the AI assistant is used, prompts and tool results go through a redaction step before anything is sent to a model provider. Redaction works two ways:
- Pattern matching masks values that look like ID or passport numbers, tax identifiers, phone numbers, email addresses, monetary figures, dates of birth, credit card numbers, and labelled bank account numbers, wherever they appear in a message or in data a tool returns.
- Field-name matching masks known sensitive field names regardless of how the value is formatted, as a second line of defense.
Masked values are replaced with placeholders such as [ID NUMBER REDACTED] or [BANK ACCOUNT REDACTED]. Per-company AI controls decide which of these are active and which provider is used. See AI Privacy & PII Handling for the full picture.
Multi-tenant isolation
Bohari IMS is a multi-tenant platform: many companies share one system, and each company's data is strictly separated. Every record that belongs to a company carries a companyId, and queries are filtered by the company you're currently working in.
Every protected endpoint applies three guards in order:
JwtAuthGuard -> TenantGuard -> PermissionsGuard
(who you are) (which company) (what you can do)
- JwtAuthGuard confirms a valid token and identifies the user.
- TenantGuard confirms a company context is present (the
X-Company-Idheader) and that the signed-in user is actually a member of that company. If they aren't, the request is rejected. - PermissionsGuard confirms the user holds the specific permission the endpoint requires, for example
view_audit_logormanage_purchase_order.
Platform administrators can operate across companies; everyone else is confined to the companies they belong to. Because the membership check happens server-side against the user's actual company list, putting a different company id in the header doesn't grant access to another tenant's data.
File security
Uploaded files are stored under a per-tenant path ({companyId}/{folder}/{fileName}), so one company's files live in a separate space from another's. Files can be served in two ways:
- Authenticated download requires a valid session, the company context, and the
manage_filespermission. - Signed URL lets a file (a compliance document, for example) load without a session, using a short signature in the URL.
Signed URLs use an HMAC-SHA256 signature over the file path. The platform verifies the signature with a constant-time comparison, so a wrong or guessed signature is rejected. Local-file downloads also resolve the path and confirm it stays inside the storage root, which blocks path-traversal attempts like ../.
HTTP security headers and input handling
- Security headers are applied with
helmet. The one exception is the signed-file route (/api/files/s/...), which skips Helmet so documents can embed in the app across origins; that route is still protected by its HMAC signature. - CORS is restricted to the configured application origin (plus
localhost:5620in development and any explicitly allowed origins), and only the headers the app needs (Content-Type,Authorization,X-Company-Id) are accepted. - Request validation runs on every endpoint with
whitelistandforbidNonWhitelistedon, so unknown fields are rejected rather than silently accepted. - Signed-file responses set
X-Content-Type-Options: nosniffand a private cache policy.
Rate limiting
Requests are throttled to absorb bursts and stop any single user or tenant from starving the system. Several tiers run at once:
| Tier | Window | Limit | Scope |
|---|---|---|---|
| short | 1 second | 50 | per IP |
| medium | 10 seconds | 200 | per IP |
| long | 60 seconds | 1000 | per IP |
| user | 60 seconds | 300 (default) | per signed-in session |
| tenant | 60 seconds | 3000 (default) | per company |
The user and tenant limits are configurable through environment variables. Sensitive auth endpoints have tighter per-route limits on top of these: login is capped at 10 requests/minute, registration at 5/minute, refresh at 30/minute, and the 2FA endpoints at 5/minute.
Audit logging
Bohari IMS records write operations for compliance. An interceptor logs every POST, PUT, PATCH, and DELETE, plus data-export downloads. Each entry captures:
- the acting user,
- the action (HTTP method and a cleaned path with record ids stripped out),
- the entity type and the affected record id,
- the IP address and user agent,
- a timestamp.
Audit entries are tenant-scoped, so a company only ever sees its own trail. Logging is fire-and-forget and never blocks or breaks the original request. To read the trail, a user needs the view_audit_log permission; results are filterable by entity type, user, and date range.
FAQ
Where are my passwords stored? Only bcrypt hashes are stored (12 rounds by default). The original password is never saved and can't be recovered, which is why a reset issues a new password rather than revealing the old one.
If someone gets my JWT, can they use it forever? No. Tokens expire (1 hour for web access tokens, 24 hours for the mobile bearer token), and expired tokens are always rejected. Web sessions rely on a rotating refresh cookie that's scoped to the auth path and replaced on every use.
Can one company see another company's data?
No. Every record is tagged with a companyId, queries are filtered by it, and the tenant guard verifies your membership in the company before any request proceeds. Supplying a different company id in the header doesn't bypass that check.
Is all of my company's operational data encrypted at rest? Field-level encryption today covers the most sensitive credentials: 2FA secrets and backup codes, integration OAuth credentials, and webhook signing secrets. Everyday operational data (stock, recipes, purchase orders, supplier records) is protected through tenant isolation and permissions rather than field-level encryption, and through PII redaction before anything is sent to an AI provider. For specifics about a particular field, contact your administrator.
Can I require everyone to use 2FA? Yes. A platform administrator can enforce 2FA platform-wide. Users who haven't enrolled are routed to set it up and can't use the rest of the platform until they do.
How long are audit logs kept and who can see them?
Audit entries are written per company and are visible to users with the view_audit_log permission. They can be filtered by entity type, user, and date range.