Skip to content

User Authentication

Applies to: All subscriptions

Purpose

Describe how users authenticate, what an administrator can configure, and what to do when someone cannot sign in — a category that accounts for a large share of first-line tickets and is usually not an authentication fault at all.

Audience

Identity administrators, system administrators, security reviewers, first-line support.

Prerequisites

Reference

Sign-in methods

Method How it works Notes
Email and password Local account, credential verified against the stored hash Always available
External identity provider The browser authenticates with the customer's identity provider Configured at front-end build time and on the API
Social sign-in Where enabled and permitted by customer policy Frequently disallowed by corporate policy — confirm before offering it
Email one-time code A code sent to the account's address Depends entirely on mail delivery

Sessions

A successful sign-in issues a signed session token, held by the browser and presented on every request. Since the 2026-09-14 session-concurrency work (docs/session-management-design.md), the server keeps a matching record of every issued session in dbo.sys_active_session — the token alone is no longer sufficient; it must also resolve to a live, non-revoked session row on each request.

Property Consequence
Signed with a server secret Changing that secret invalidates every session immediately. A deliberate act, not a routine restart
Carries identity and scope Which is why scope is never taken from client input
Time-limited — default 8 hours (SESSION_TTL_HOURS) Users re-authenticate when it expires, whichever comes first against the session row's own expiry
Tracked server-side A session can now be ended before its token expires — see Logout below. Scaling out still requires no session affinity (any API instance can check any session), but it does now depend on all instances sharing the same database

Concurrent sessions

By default, a user may have one active session at a time (SESSION_MAX_CONCURRENT, default 1).

Signing in beyond that limit does not silently sign anything out. The sign-in pauses: the person is shown their currently active sessions (device, approximate location, last activity) and explicitly chooses which one to end before this new sign-in completes — or does nothing, and the attempt simply expires after 5 minutes with no session ended anywhere. This applies to password sign-in, Microsoft/Google sign-in, and email one-time-code sign-in alike.

Once a session is ended this way — chosen by the person, not picked automatically — its next request returns 401 SESSION_REVOKED and that device must sign in again.

Raise SESSION_MAX_CONCURRENT (e.g. to 2) for a platform-wide default that suits a workforce that legitimately works from more than one device at once — see Environment Variables.

For a single organisation, an administrator can override this from Administration → Users & Roles → Session Policy (admin.manage_users permission) without touching platform configuration — this takes effect for that organisation only, immediately, and can be reset back to the platform default from the same panel. An organisation with no override uses the platform default.

For one person within an organisation, the same panel's Per-user exceptions section can set a limit that differs from the org-wide setting — e.g. someone who genuinely needs to stay signed in on two devices at once — without raising the limit for everyone else in that organisation. The effective limit at sign-in is resolved in order: the user's own exception, else their organisation's override, else the platform default. A user with no exception set simply inherits the organisation's setting.

Note for anyone who used this platform before 2026-09-15: sign-in used to evict an older session automatically and silently the moment the limit was reached, with no confirmation step. That behaviour was replaced by the explicit choice described above.

Bootstrap session validation

Every browser tab confirms its session is still genuinely alive with the server before rendering as signed in, not just by checking its own token's expiry locally. This closes a gap that applied to any way a session can end — logout-everywhere, a concurrent-session-limit eviction, or an admin-forced logout — not only the concurrent-limit feature above: a tab that already held a since-revoked session used to be able to render as authenticated, indistinguishable from a genuinely live one, until the first action that actually reached the server proved otherwise. Now that check happens once, immediately, at load.

What this looks like in practice: a brief loading state on every page load/reopen before the app shell renders — one extra round-trip to confirm the session, not a new sign-in step. If the session is no longer valid, the tab goes straight to the sign-in screen instead of briefly showing content it has no right to display.

Browser tabs are not counted as separate sessions. Multiple tabs open in the same browser share that browser's local storage, so a second tab in the same browser window is a second view onto whichever session is currently active there, not a second concurrent login. The concurrent-session limit above counts real, distinct sign-ins — different browsers, different devices — which is what it was always intended to enforce.

Logout

Action Endpoint Effect
Sign out (this device) POST /api/auth/logout Revokes only the session the caller's own token names
Sign out everywhere POST /api/auth/logout-all Revokes every active session for the user, including the one making the call
End one other session DELETE /api/auth/sessions/:sessionId From Security Settings > Active Sessions — refuses to end the caller's own current session unless explicitly confirmed
Administrator ends every session of a user POST /api/admin/users/{userId}/end-sessions From Admin & Logs > Users & Roles. Needs admin.manage_users, and only for a user in the administrator's own company. The account stays active

A revoked session's token is rejected on its very next use (401 SESSION_REVOKED), regardless of how much of its 8-hour lifetime remains. An administrator's changes reach a signed-in person the same way: disabling or deleting the account, or changing the role, approval role or GSTIN access, ends every session the person holds, and the 401 carries a reason and a sentence the sign-in page shows them. See SESSION_REVOKED says why. Before this work, "logout" cleared the browser's local copy of the token only — the token itself stayed valid server-side until it naturally expired, so a signed-out device (or a copy of the token taken from it) could still act as the user for up to 8 hours. That gap is closed.

Password rule and sign-in protection

What the product enforces today, stated so a security questionnaire can be answered from it:

Control What is enforced
Password rule At least 8 characters, at most 72 bytes (about 72 letters or digits; fewer with accented or non-Latin characters), and not only spaces. No complexity requirement (no capitals, digits or symbols). The same rule applies when a user changes their own password, resets it by email, or an administrator creates the account
Why 72 bytes Passwords are stored with bcrypt, which ignores everything after byte 72. A longer password is refused rather than silently truncated
Repeated failures A per-address throttle: after AUTH_RATE_LIMIT_MAX failed requests (default 20) from one client address in 15 minutes, further requests from that address are refused until the window passes. Successful sign-ins do not count. See Environment Variables
Alerting Failed sign-ins raise a LOGIN_FAILURE_SPIKE alert (25 in 15 minutes across all accounts, or 10 against one account)

What it does not do, stated plainly for a reviewer:

  • There is no per-account lockout. A wrong password never disables an account. The throttle is per client address, so an attacker rotating addresses is not stopped by it; the alert is what draws attention.
  • There is no multi-factor authentication for password sign-in. One-time codes by email are used for password reset and activation, not as a second factor. An organisation that needs MFA signs in through its own identity provider (see above), which enforces it.
  • Credential reuse (password history) is not checked.

Verify: Complifly has not stated a commitment to add per-account lockout, multi-factor authentication or a password-history rule. Treat them as absent until it does. Recorded as assumption E3 in the Assumptions Register. Session lifetime and concurrency policy are separate; see assumption E6.

Everything depends on mail

Password reset, one-time codes and account activation all require a delivered email. If mail is misconfigured, users are locked out and nothing indicates why:

  • No error appears in the interface.
  • Nothing fails at application startup — mail credentials are validated at send time.
  • The ticket that reaches support says "I cannot log in", which sends the responder to authentication rather than to mail.

When several users cannot sign in at once, check mail delivery first. It is the most common cause and the least obvious.

GSTIN access is separate from sign-in

Authentication establishes who a user is. Two further gates decide what they see:

Gate Controls
Role Which actions they may perform. See Roles and Permissions
GSTIN access Which registrations they may work with

A user who signs in successfully and sees nothing usually has no GSTIN access, not an authentication problem. This distinction resolves a large share of "it is broken" reports.

Which company a request acts for

The company is taken from your signed token, and from nothing else. A client may send an X-Org-Id header (the web application does, for convenience); the server ignores it. A token that carries no company is not given a default one: a request that needs a company answers 400 or 403 rather than quietly acting for someone else.

Company status and sign-in

Sign-in is refused for an archived company, whichever method is used (password, Microsoft or Google sign-in, or a one-time code), with 403 TENANT_ARCHIVED and no session created. A token issued before the archiving stops working at once. A suspended company is not refused. A Complifly Super Admin is never locked out of sign-in, so there is always a way to reactivate a company. See Company Status.

Account lifecycle

Stage Action Note
Create Administrator creates the account with a role and GSTIN access The email must be one the user actually reads
Activate The user completes activation from an emailed message Blocked entirely if mail is not working
Operate Normal use —
Change role Administrator updates it Signs the person out of every device at once; the new role applies when they sign in again. Changing the approval role or GSTIN access does the same
Suspend Disable the account Use for temporary removal of access. Signs the person out of every device at once
Revoke Disable on departure Do not delete. Deletion breaks the attribution of past actions in the audit trail. Disabling also ends any session they hold

The final row matters for audit: a compliance system must be able to say who performed an action years later. Disable, never delete.

Development-only sign-in conveniences

Development builds may include conveniences that bypass normal sign-in. These must be absent or disabled in every production build. Verify explicitly before publishing a production front-end bundle — see Front-end Deployment.

Validation

Check Method Pass condition
Sign-in works Sign in as a test user Reaches the landing screen
External identity provider works Sign in through it Succeeds and the redirect returns correctly
Password reset works Trigger a reset to a real mailbox Message arrives in the inbox and the reset completes
One-time codes arrive in time Trigger a code Delivered well inside its validity period
Repeated failures are throttled Send more than AUTH_RATE_LIMIT_MAX failed sign-ins from one address inside 15 minutes Further requests from that address are refused (429) until the window passes. The account itself is not locked
A short password is refused Change a password to 5 characters 400 PASSWORD_TOO_SHORT, and the old password still works
Role gating works Sign in as a limited role and attempt a restricted action Refused or greyed out
GSTIN gating works Sign in as a user with access to one registration Only that registration's data is visible
Session expiry works Leave a session idle past its lifetime Re-authentication required
Logout revokes the session Sign in, note the token, sign out, then replay a request with that same token 401 SESSION_REVOKED
Concurrent-session limit works Sign in on a second device while SESSION_MAX_CONCURRENT is 1 Sign-in pauses and shows the first device as an active session to choose from — not an immediate success
Choosing a session completes sign-in From the pause above, choose the listed session Sign-in completes; the chosen device's next request returns 401 SESSION_REVOKED
Per-org session policy works As an admin, set Session Policy to a different value from the platform default in Users & Roles, then repeat the check above from a test account in that org The org-specific value is enforced, not the platform default
Per-user session exception works Set a Per-user exception for one test account to a different value than its organisation's policy, then repeat the check above from that account and from another account in the same org Only the excepted account sees the different limit; the other account still follows the org policy
Bootstrap validation actually validates Sign in, then in a different browser (its own storage, so a genuinely separate session) invoke Sign out everywhere for that account, then reload the first browser's tab without clicking anything in it first The reloaded tab is sent to sign-in — it does not render as signed in even briefly
Revocation works Disable an account and attempt to sign in Refused
No development bypass in production Search the published front-end bundle for the flag names Absent
Attribution works Perform an action and inspect the audit trail The action is attributed to the correct user

Troubleshooting

Symptom Cause Action
Several users cannot sign in at once Mail delivery failure, not authentication Check mail first. See Email and Notifications
One user cannot sign in Wrong password, locked out, or account disabled Check the account state before anything else
Password reset email never arrives Mail misconfigured, wrong address, or filtered as spam Confirm the address, then test delivery to a known-good mailbox
One-time code expires before it is read Relay latency exceeds the code's validity Reduce latency, or extend the lifetime deliberately
Everyone signed out at once The token signing secret changed Expected if intended. If not, investigate what changed the configuration
Signs in successfully but sees nothing No GSTIN access Grant access. Not an authentication fault
Signs in but every button is greyed out Role lacks the permissions Fix the role. See Roles and Permissions
External sign-in redirect fails Redirect URL not registered, or a protocol mismatch Register the exact URL, including scheme and path
Social sign-in blocked by the customer Corporate policy Confirm the policy before enabling it
A departed employee's past actions lost from the audit trail The account was deleted rather than disabled Disable, never delete. Restore attribution if possible
Sessions expire too quickly for users Lifetime shorter than the working pattern Adjust SESSION_TTL_HOURS
User is unexpectedly signed out while working, no error they can explain Someone (often the same user, on another device) signed in and explicitly chose to end this session from the sign-in screen Check Security Settings > Active Sessions. If this is expected usage for one person only, set a Per-user exception for that account rather than raising the org-wide Session Policy; if it's expected for the whole org, raise the org-wide setting instead of the platform-wide SESSION_MAX_CONCURRENT
User reports sign-in "got stuck" / didn't complete on a new device The concurrent-session limit was reached and sign-in is paused, waiting for them to choose a session to end Not stuck — direct them to the list shown on screen. If they abandon it, nothing is signed out and the attempt expires after 5 minutes