# Token endpoint

> POST /api/oauth/token — exchange an authorization code for tokens, or refresh them. Both grants, the request and response shapes, refresh-token rotation, and family revocation on replay.

The token endpoint mints tokens. It handles two grants: **`authorization_code`** (exchange the code from [`/authorize`](/docs/oauth/authorize/)) and **`refresh_token`** (rotate an expiring session). It is a machine endpoint — **CSRF-exempt and CORS-open (`*`)**, because it carries client credentials and PKCE proofs, never cookies.

```http
POST /api/oauth/token
Content-Type: application/x-www-form-urlencoded
```

Send `application/x-www-form-urlencoded` (JSON is also accepted). Client authentication is `client_secret_basic` (HTTP Basic), `client_secret_post` (secret in the body), or `none` for public clients — where PKCE is the proof.

## Grant: `authorization_code`

Exchange the one-time code for tokens.

### Request

| Field | Required | Notes |
|---|---|---|
| `grant_type` | ✓ | `authorization_code` |
| `code` | ✓ | The code from the redirect (60-second TTL, single use). |
| `redirect_uri` | ✓ | Must equal the authorize request's `redirect_uri` byte-for-byte. |
| `code_verifier` | ✓ | The PKCE verifier (43–128 chars) whose S256 hash was sent to `/authorize`. |
| `client_id` | ✓ | Your client. |
| `client_secret` |  | Confidential clients only (or use HTTP Basic). |

```bash
# --data-urlencode form-encodes each value safely (redirect URIs carry reserved characters)
curl -X POST https://entrybit.net/api/oauth/token \
  --data-urlencode "grant_type=authorization_code" \
  --data-urlencode "code=SplxlOBeZQQYbYS6WxSbIA" \
  --data-urlencode "redirect_uri=entrybitresident://oauth/callback" \
  --data-urlencode "client_id=eb_9f1c2ab34cd56ef7" \
  --data-urlencode "code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
```

### Response

```json
{
  "access_token": "<RS256 JWT>",
  "token_type": "Bearer",
  "expires_in": 900,
  "scope": "openid profile email offline_access passes:read",
  "refresh_token": "<opaque>",
  "id_token": "<RS256 JWT>"
}
```

`Cache-Control: no-store` is set on every token response. The `id_token` is present when `openid` was granted; the `refresh_token` is present when `offline_access` was granted.

> **Auth-code-injection defense:** a **replayed** authorization code revokes the entire refresh-token family it originally minted. Never exchange a code twice.

## Grant: `refresh_token`

Exchange the current refresh token for a fresh set — with a **new refresh token**.

### Request

| Field | Required | Notes |
|---|---|---|
| `grant_type` | ✓ | `refresh_token` |
| `refresh_token` | ✓ | The **current** refresh token (they rotate on every use). |
| `client_id` | ✓ | Your client. |
| `client_secret` |  | Confidential clients only (or use HTTP Basic). |
| `scope` |  | Optional **narrowing** of the granted scope — never widening. |

```bash
# A refresh token is a live credential — keep it in your secret store, not the command line
curl -X POST https://entrybit.net/api/oauth/token \
  --data-urlencode "grant_type=refresh_token" \
  --data-urlencode "refresh_token=$REFRESH_TOKEN" \
  --data-urlencode "client_id=eb_9f1c2ab34cd56ef7"
```

The response has the same shape as the authorization-code grant — **including a brand-new `refresh_token`**.

## Refresh-token rotation (read this)

Refresh tokens are **opaque, single-use, and rotate on every exchange**. The rules that keep a session healthy:

- **Always persist the newest `refresh_token`** returned by each refresh. The old one is now dead.
- **Never keep two copies, and never refresh the same token twice concurrently.** Presenting a superseded or revoked token is treated as theft: EntryBit revokes the **entire token family** (the stolen-token kill switch) and returns a generic `invalid_grant`.
- On `invalid_grant`, **clear your tokens and send the user to log in again.**

In code, the contract is an ordering — persist the new refresh token *before* the new access token is used, and keep at most one refresh in flight:

```ts
// Sketch — a complete client (single-flight + invalid_grant handling) is in
// the React Native quickstart's lib/session.ts.
const tokens = await postTokenRefresh(stored.refreshToken); // ONE at a time, app-wide

await secureStore.set('refreshToken', tokens.refresh_token); // 1. the old token is dead
authorizeWith(tokens.access_token);                          // 2. only then use the new pair
```

Refresh tokens live **60 days**; access tokens live **~15 minutes**. Access tokens are stateless JWTs — they simply expire and cannot be revoked individually (revoke the refresh token to end a session; see [Revocation](/docs/oauth/introspection-revocation/)).

## The tokens

| Token | Format | Lifetime | Claims / notes |
|---|---|---|---|
| **access_token** | RS256 JWT, `typ: at+jwt` ([RFC 9068](https://www.rfc-editor.org/rfc/rfc9068)) | ~15 min | `iss sub aud client_id scope iat nbf exp jti`; `aud` = your `client_id`. |
| **id_token** | RS256 JWT, `typ: JWT` | — | `iss sub aud iat exp` + `nonce? auth_time? amr? at_hash?` + scope-gated identity. See [ID token & UserInfo](/docs/oauth/id-token-userinfo/). |
| **refresh_token** | Opaque, SHA-256 stored | 60 days | Single-use, rotating. |

Both JWTs verify **offline** against the [JWKS](/docs/oauth/discovery-jwks/).

## Errors

| Status | `error` | Meaning |
|---|---|---|
| `400` | `invalid_grant` | Code/refresh expired, reused, mismatched, or family-revoked. |
| `400` | `invalid_request` | A required parameter is missing or malformed. |
| `400` | `invalid_scope` | Requested a scope the client (or grant) doesn't allow. |
| `400` | `unsupported_grant_type` | `grant_type` is not `authorization_code` or `refresh_token`. |
| `401` | `invalid_client` | Client authentication failed. |
| `429` | `temporarily_unavailable` | Rate limited — back off and retry. |

Descriptions are deliberately generic (no token-state oracle). Branch on the `error` code — see [Errors](/docs/api-reference/errors/).