Authentication & Permissions

How erxes identifies team members, portal visitors, and machine apps, and where each credential is checked.

For the user model, see Organizations, Users & Permissions. For portal-specific flows, see Client Portal.

Credential types

All of these are checked by userMiddleware in backend/gateway, before the request reaches Apollo Router.

CredentialHow issuedTransportIdentifiesWhere checked
Team-member sessionlogin mutation (or WorkOS SSO); JWT { user: { _id, isOwner } }, 1-day expiry, registered in Redis user_token_<id>_<token> for 24hAuthorization: Bearer <jwt> or auth-token httpOnly cookieA Users documentGateway: JWT signature + user lookup + Redis key must exist
OAuth access token/oauth/token (device-code flow)Same as team member, JWT carries typ: "oauth_access", clientId, scopeA user acting through an OAuthClientApps clientGateway; scopes land on req.user.oauthScopes
App tokenappsAdd mutation (requires appsManage); random string on the app_tokens collectionerxes-app-token headerA machine principal app:<id>; isOwner when allowAllPermissionGateway: Apps.findOne({ token, status: "active" }); 401 on miss
CORS-bypass app tokenSame app_tokens recordx-app-api-token headerOrigin-check bypass onlyGateway CORS middleware; result cached in Redis 1h
Client Portal tokenclientPortalAdd / clientPortalChangeToken; JWT { clientPortalId } signed with JWT_TOKEN_SECRET, no expiryx-app-token headerA ClientPortals recordGateway: JWT verify + portal lookup → req.clientPortal
Portal-user sessionclientPortalUser* auth mutations; JWT { userId, clientPortalId }, 1d access / 7d refresh defaultsclient-auth-token header or httpOnly cookieA CPUsers record bound to that portalGateway, only after a valid x-app-token
POS config tokenSales POS setup; posChooseConfig sets the cookieerxes-pos-token header or pos-config-token cookieA posclient Configs recordposclient_api config middleware
POS user sessionposLoginpos-auth-token cookieA posclient PosUsers recordposclient_api user middleware
Website checkerxes-hosted verificationerxes-core-token + erxes-core-website-url headersSynthetic user limited to showIntegrations, showKnowledgeBase, showScriptsGateway: POST to https://erxes.io/check-website must answer ok

Check order in userMiddleware: website check → app token → client portal (x-app-token, then client-auth-token) → Bearer/auth-token user JWT. All credential types can ride the same request, for example an app token plus a portal token.

Login example

mutation Login($email: String!, $password: String!) {
  login(email: $email, password: $password)
}

login returns the access token as a String and also sets the auth-token cookie (httpOnly; secure/sameSite depend on the request). Internally Users.createTokens (Users.ts) also mints a 7-day refresh JWT; logout clears the cookie and drops the Redis key, which revokes the token immediately rather than at JWT expiry.

curl --fail https://<gateway>/graphql \
  -H 'Content-Type: application/json' \
  -H "Authorization: Bearer <token>" \
  --data '{"query":"query { currentUser { _id email } }"}'

SSO and OAuth

  • WorkOS SSO (SaaS only): loginWithGoogle and loginWithMagicLink call assertSaasEnvironment() and build WorkOS authorization/session URLs using WORKOS_API_KEY, WORKOS_PROJECT_ID, and CORE_DOMAIN. Magic-link mail goes out through SendGrid.
  • OAuth 2.0 device flow: Core API mounts /oauth/device/code, /oauth/device/approve, /oauth/device/deny, /oauth/token, and /oauth/revoke (oauth/routes.ts; device-code grant with per-IP rate limits). Client apps are managed through oauthClientAppsAdd/Edit/Revoke/Remove with public or confidential types and per-client token lifetimes.

What the gateway forwards

Subgraphs never see your raw credentials. The middleware rewrites them into signed-by-position headers read by erxes-api-shared context builders:

HeaderContents
userBase64 JSON of the compacted user document (emailSignatures, customFieldsData, propertiesData, links, loginToken stripped)
useridThe user _id (also set for app:<id> principals)
clientportalBase64 JSON of the resolved portal
cpuserBase64 JSON of the portal user
sessioncodePassed through to req.user.sessionCode

Never trust these headers at the edge

user, clientportal, and cpuser are unsigned; they are trustworthy only because the gateway sets them. Expose only the gateway publicly: direct access to a subgraph port bypasses authentication. JWT_TOKEN_SECRET must be identical across gateway, Core API, and plugins, and must be set explicitly in production. For portal JWTs the shared code throws in NODE_ENV=production rather than falling back to the 'SECRET' default.

Permissions

Team-member authorization runs through checkPermission / wrapPermission in erxes-api-shared/core-modules/permissions. Wrapped resolvers throw Login required (UNAUTHORIZED) with no context.user, then check the action list the plugin declares in meta/permissions.ts (canGroup → permission-group actions, isOwner bypass, checkOAuthScope for oauth_access tokens). Portal resolvers instead use wrapPublicResolver with forClientPortal/cpUserRequired flags, throwing Client portal required / Client portal user required. cp* operations and widget resolvers skip the team-member wrapper (wrapperConfig.skipPermission) and scope by portal/integration context; the cp prefix does not itself imply published-only or read-only data.

Edge cases

  • Sending two authorization or erxes-core-token headers is a 400, not a pick-one.
  • An expired, malformed, or Redis-evicted user token is ignored, not rejected: the request continues anonymous and wrapped resolvers fail with Login required. Only an unknown erxes-app-token gets an explicit 401.
  • WebSocket subscriptions authenticate from the auth-token cookie on the upgrade request; connectionParams are not consulted.
  • Set DEBUG_GATEWAY_AUTH=true on the gateway to get structured JSON logs for every auth decision on /graphql (which credential was present, which check failed, token hash).
Was this helpful?