# Authentication & Permissions

Team, portal, app, and agent credentials


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

For the user model, see [Organizations, Users & Permissions](https://erxes.io/docs/core-organizations-users). For portal-specific flows, see [Client Portal](https://erxes.io/docs/app-client-portal).

## Credential types

All of these are checked by [userMiddleware](https://github.com/erxes/erxes/blob/f91e8c6f4714c910a32c6b6e14fe085f6435c89e/backend/gateway/src/middlewares/userMiddleware.ts) in `backend/gateway`, before the request reaches Apollo Router.

| Credential | How issued | Transport | Identifies | Where checked |
| --- | --- | --- | --- | --- |
| Team-member session | `login` mutation (or WorkOS SSO); JWT `{ user: { _id, isOwner } }`, 1-day expiry, registered in Redis `user_token_<id>_<token>` for 24h | `Authorization: Bearer <jwt>` or `auth-token` httpOnly cookie | A `Users` document | Gateway: 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`, `scope` | A user acting through an `OAuthClientApps` client | Gateway; scopes land on `req.user.oauthScopes` |
| App token | `appsAdd` mutation (requires `appsManage`); random string on the `app_tokens` collection | `erxes-app-token` header | A machine principal `app:<id>`; `isOwner` when `allowAllPermission` | Gateway: `Apps.findOne({ token, status: "active" })`; 401 on miss |
| CORS-bypass app token | Same `app_tokens` record | `x-app-api-token` header | Origin-check bypass only | Gateway CORS middleware; result cached in Redis 1h |
| Client Portal token | `clientPortalAdd` / `clientPortalChangeToken`; JWT `{ clientPortalId }` signed with `JWT_TOKEN_SECRET`, no expiry | `x-app-token` header | A `ClientPortals` record | Gateway: JWT verify + portal lookup → `req.clientPortal` |
| Portal-user session | `clientPortalUser*` auth mutations; JWT `{ userId, clientPortalId }`, 1d access / 7d refresh defaults | `client-auth-token` header or httpOnly cookie | A `CPUsers` record bound to that portal | Gateway, only after a valid `x-app-token` |
| POS config token | Sales POS setup; `posChooseConfig` sets the cookie | `erxes-pos-token` header or `pos-config-token` cookie | A posclient `Configs` record | `posclient_api` config middleware |
| POS user session | `posLogin` | `pos-auth-token` cookie | A posclient `PosUsers` record | `posclient_api` user middleware |
| Website check | erxes-hosted verification | `erxes-core-token` + `erxes-core-website-url` headers | Synthetic user limited to `showIntegrations`, `showKnowledgeBase`, `showScripts` | Gateway: 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

```graphql
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](https://github.com/erxes/erxes/blob/f91e8c6f4714c910a32c6b6e14fe085f6435c89e/backend/core-api/src/modules/organization/team-member/db/models/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.

```bash
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](https://github.com/erxes/erxes/blob/f91e8c6f4714c910a32c6b6e14fe085f6435c89e/backend/core-api/src/modules/auth/routes/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:

| Header | Contents |
| --- | --- |
| `user` | Base64 JSON of the compacted user document (`emailSignatures`, `customFieldsData`, `propertiesData`, `links`, `loginToken` stripped) |
| `userid` | The user `_id` (also set for `app:<id>` principals) |
| `clientportal` | Base64 JSON of the resolved portal |
| `cpuser` | Base64 JSON of the portal user |
| `sessioncode` | Passed through to `req.user.sessionCode` |

> [!WARNING]
> **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).

