# Tokens and security


Source: https://docs.stxapp.io/oauth/tokens-and-security/

The token lifecycle and the security properties you can rely on. Read it before you go to production. `$STX` in the examples is the base URL STX gives you with your invite.

## The tokens

STX issues opaque tokens. They are not JWTs, carry no readable claims, and their meaning lives only in STX. Treat them as strings.

| Token | Prefix | Lifetime | Use |
| --- | --- | --- | --- |
| Access token | `stxapp_at_` | `expires_in`, 3600 seconds | Bearer on every call |
| Refresh token | `stxapp_rt_` | Expires after 14 days unused, and capped (see [Refresh lifetime](#refresh-lifetime)) | Get a new pair, no member involved |
| Authorization code | `stxapp_code_` | 60 seconds, single use | Exchanged once for the first pair |

Every STX credential has the form `stxapp_<type>_` followed by 43 base62 characters and a 6-character checksum: `stxapp_at_` access token, `stxapp_rt_` refresh token, `stxapp_code_` authorization code, `stxapp_cs_` client secret, `stxapp_client_` client id. The fixed shape lets secret scanners spot a leaked credential. Store the whole string.

An app token is a `stxapp_at_` access token from `client_credentials`, with no refresh token and no member.

Hold tokens on your server where you have one, encrypted at rest, per member. A Browser app or Mobile or desktop app keeps them in memory or the platform's secure storage, never in a URL, a log or a shipped bundle.

## Refreshing

```bash
curl -s -u "$CLIENT_ID:$CLIENT_SECRET" "$STX/oauth/token" \
  -d grant_type=refresh_token \
  -d refresh_token="$REFRESH_TOKEN"
```

You receive a new access token and a new refresh token; the old refresh token is retired. **Always store the new refresh token.**

### Rotation and reuse detection

A retired refresh token presented again is treated as stolen: STX revokes the whole grant. Both the thief and your app lose access, and the member must reconnect. A 60-second grace window lets an ordinary retry of the same refresh return the same successor instead of tripping this. Never refresh with a token you have already rotated past.

### Refresh lifetime

A refresh token not used for 14 days expires. Every refresh chain also ends at a fixed cap counted from the member's approval: 90 days for a Web app or Server-to-server app, 30 days for a Browser app or Mobile or desktop app. Refreshing never extends it. Past either limit a refresh returns `400 invalid_grant`; send the member through the authorization flow again to re-approve.

### When a refresh fails

A failed refresh is `400` with `error: "invalid_grant"` and an `error_description` saying why (reused, expired, or invalid); see [token endpoint errors](/oauth/discovery-and-errors/#token-endpoint-errors). Treat it as terminal for that member: clear the stored pair and send them back through consent. A `503` with `temporarily_unavailable` means the refresh was not judged; retry with the same token.

## Revoking

To disconnect a member from your side (sign-out, account deletion):

```bash
curl -s -u "$CLIENT_ID:$CLIENT_SECRET" "$STX/oauth/revoke" -d token="$ACCESS_TOKEN"
```

- Revoking either token of a pair kills both. Other pairs under the same grant (another device of yours) are untouched.
- The answer is `200` for a known, unknown, or another client's token alike, so it never reveals whether a token was valid. `503` means retry.
- A Browser app or Mobile or desktop app revokes with `client_id` in the form instead of a secret.

A member can also revoke your app from [Connected apps](/isv/hosted-pages/#connected-apps) in their STX account. That revokes the whole grant and every token under it; your next call for that member returns `401`.

If STX suspends, closes, bans or otherwise restricts a member's account, your tokens for that member stop working at once and the grant is revoked; the member must approve your app again once reinstated. A temporary cool-off (the `cool_off` account status) only pauses access until it lifts.

When a token is revoked, rotated or expires, STX closes any WebSocket opened with it. The member's own sessions are unaffected.

## Checking a token

`POST /oauth/introspect` with `token=` answers `{"active": true, "scope": "…", "client_id": "…", "token_type": "bearer"}` for a live access token of your own, and `{"active": false}` for anything else, including another client's token. It requires `client_secret_basic`. The reported `scope` is the effective scope. Use it for debugging, not the hot path; trust the `401` and `403` from the API.

## Apps without a client secret

A Browser app or Mobile or desktop app (see [application types](/isv/#application-types)) has `token_endpoint_auth_method: "none"`. It has no secret, sends no `Authorization` header to `/oauth/token` or `/oauth/revoke`, and identifies itself with a `client_id` form field; its proof is the PKCE verifier. It can redeem codes and refresh, and cannot use `client_credentials` or introspection.

## Dynamic registration

**Dynamic client registration** (RFC 7591) is `POST /oauth/register`, unauthenticated, JSON body. It is **off by default**: while off it answers `404` and the metadata has no `registration_endpoint`.

| Field | |
| --- | --- |
| `client_name` | required |
| `redirect_uris` | `https`, or a reverse-domain custom scheme or `http://localhost` / `127.0.0.1` / `[::1]` (any port) for an installed app; none needed for `client_credentials` only. No fragment |
| `scope` | optional; space-separated known scopes, member or app |
| `token_endpoint_auth_method` | `client_secret_basic` or `none`. If omitted: `none` when any callback is local or a custom scheme, otherwise `client_secret_basic`. The response echoes the method chosen |
| `grant_types` | subset of `authorization_code refresh_token client_credentials`, default `authorization_code refresh_token`; `refresh_token` needs `authorization_code`; `client_credentials` needs a secret |
| `response_types` | `["code"]` |
| `client_uri`, `logo_uri` | optional absolute `https` URLs |
| `software_id`, `software_version` | optional strings |

Without `scope`, the client is allow-listed for the read-only default `profile.read balance.read portfolio.read orders.read`, plus the app scopes when `grant_types` includes `client_credentials`. It cannot request a write scope (`orders.write`, `terms.write`): those need an IP allowlist, which only STX sets. Register with read scopes and ask STX to add the write scope. A request that names one fails with `400 invalid_client_metadata`. `201` returns `client_id`, `client_id_issued_at`, the stored metadata and, for an app with a secret, a one-time `client_secret` with `client_secret_expires_at: 0`. Errors are listed under [registration errors](/oauth/discovery-and-errors/#registration-errors).

## Security properties you can rely on

- **PKCE is mandatory**, `S256` only. An intercepted code is useless without the verifier your server holds.
- **Codes are single use.** Presenting a code twice revokes the member's grant to your app.
- **Callbacks are matched exactly**, byte for byte, at both the authorize and token steps, except a local callback (`localhost`, `127.0.0.1`, `[::1]`), which matches on any port. An unknown client or callback gets an error page on STX and is never redirected.
- **An IP allowlist guards writes.** When your app has an IP allowlist, token requests, REST calls and WebSocket connections from any other address are refused with `403 ip_not_allowed`. Any write scope requires one.
- **Tokens are bound to your client.** Refresh, revoke and introspect check that the token belongs to the authenticating client.
- **Scope is re-checked on every call** against the member's current grant and your client's current allow-list; see [effective scope](/oauth/scopes/#effective-scope).
- **Suspending a client cuts its tokens.** A client STX suspends or revokes stops resolving on its members' live tokens and its app tokens at once.
- **Two-factor stays with STX.** It is enforced at STX sign-in; your app only receives tokens after the member has authenticated.
- **No scope moves money.** See [Scopes](/oauth/scopes/#no-scope-moves-money).

## A checklist for production

- Store `client_secret`, access and refresh tokens server-side and encrypted where you have a server.
- Verify `state` on every redirect back; keep the PKCE verifier per pending sign-in.
- Refresh proactively; store the rotated refresh token every time; never present one twice.
- Handle `401` (refresh, then reconnect) and `403` (ask for the named scope) distinctly; see [API errors](/oauth/discovery-and-errors/#api-errors).
- Handle `403 ip_not_allowed` separately from `403 insufficient_scope`: call from an address on your allowlist.
- Revoke on sign-out, and expect members to revoke you.
- Request the fewest scopes that do the job.
