> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.id.me/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.id.me/_mcp/server.

# Refresh token error reasons

> Use the error_reason field to tell why a refresh token request failed, and handle each cause correctly.

When a refresh token request fails, IDMe can tell your application why with a short, fixed code called `error_reason`. Your application can then react correctly. For example, it can send the member to sign in again instead of retrying a token that will never work.

> **Note**
>
> `error_reason` is off by default and is turned on separately for each integration. To turn it on, contact your IDMe account team.

## Why it exists

A failed refresh returns a generic `invalid_grant` error. The only explanation is an English sentence in `error_description`, which is written for people and can change. Applications that cannot tell the causes apart often retry the same dead refresh token. That adds failed calls and leaves members stuck instead of signed back in.

`error_reason` adds one field to the failed refresh response. The HTTP status, `error`, and `error_description` do not change.

## How it works

```mermaid
flowchart LR
    A["Your app sends<br />the refresh token"] --> B["IDMe checks<br />the token"]
    B --> C{"Valid?"}
    C -- yes --> D["200 OK<br />save the new tokens"]
    C -- no --> E["400 invalid_grant<br />+ error_reason"]
    E -- no retry --> F["Your app logs the reason<br />and signs the member in again"]
```

1. Your application sends the refresh token to IDMe, as it does today.
2. If the token is valid, IDMe returns new tokens. Successful responses do not change.
3. If the token is not valid, IDMe returns the usual `invalid_grant` error and adds `error_reason`, a code that names the cause.
4. Your application records the code and sends the member to sign in again. It does not retry the same refresh token.

## Availability

`error_reason` is set per integration (OAuth client) and per environment. We recommend turning it on in sandbox first. Tell your account team which integration and environment you want. Turning it on or off needs no code change from you.

|                                 | `error_reason` off (default) | `error_reason` on                              |
| ------------------------------- | ---------------------------- | ---------------------------------------------- |
| HTTP status of a failed refresh | 400                          | 400 (unchanged)                                |
| `error`                         | `invalid_grant`              | `invalid_grant` (unchanged)                    |
| `error_description`             | Human-readable sentence      | Same sentence (unchanged)                      |
| `error_reason`                  | Not present                  | One of the four values below, when one applies |
| Other grant types and endpoints | Unchanged                    | Unchanged                                      |

## Response

`error_reason` appears only on failed `POST /oauth/token` requests with `grant_type=refresh_token`. The request does not change:

**`Request`**

```bash Request
curl -X POST https://api.id.me/oauth/token \
  -d grant_type=refresh_token \
  -d client_id=$CLIENT_ID \
  -d client_secret=$CLIENT_SECRET \
  -d refresh_token=$REFRESH_TOKEN
```

Public clients that use [PKCE](/guides/o-auth-2-0/pkce) send no `client_secret`. Credentials can also be sent in an HTTP Basic `Authorization` header. `error_reason` works the same way in every case.

**`Response (HTTP 400)`**

```json Response (HTTP 400)
{
  "error": "invalid_grant",
  "error_description": "The refresh token provided has already been used",
  "error_reason": "refresh_token_revoked"
}
```

### Values

The values are fixed codes for your code to compare. Write your handling so that a value you don't recognize is treated as a failed refresh.

| `error_reason`          | What happened                                                                                                                                                                              | `error_description`                                     |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------- |
| `refresh_token_invalid` | IDMe does not recognize the refresh token.                                                                                                                                                 | The refresh token provided is invalid                   |
| `refresh_token_revoked` | The refresh token was already used, or it was revoked (for example, your application called `/oauth/revoke`). Each successful refresh returns a new refresh token and retires the old one. | The refresh token provided has already been used        |
| `refresh_token_client`  | The refresh token was issued to a different OAuth client.                                                                                                                                  | The refresh token provided was issued to another client |
| `refresh_token_expired` | The refresh token is past its expiry. Each successful refresh starts a new refresh token lifetime.                                                                                         | The refresh token provided has expired                  |

IDMe checks these in the order listed and returns the first one that applies. For example, a used refresh token presented by a different client returns `refresh_token_revoked`.

### When the field is absent

The response has no `error_reason` in these cases, even when the field is turned on:

* The request has no `refresh_token` parameter. This returns `invalid_request`.
* Client authentication fails, for example because of an unknown `client_id` or a wrong `client_secret`. This returns HTTP 401 `invalid_client` before IDMe looks at the refresh token.
* The member's account can no longer sign in, for example because it is suspended. This returns `invalid_grant` with no reason, so the response does not reveal the account's status.
* The request uses any other grant type, such as `authorization_code`.
* The refresh succeeds.

## Handling refresh failures

On any `invalid_grant` response to a refresh request, with or without `error_reason`, stop using that refresh token. It will not succeed. Discard it and send the member through sign-in to get a new token pair. Use `error_reason` to log the cause, show the right message, and find problems in your own token storage.

| `error_reason`                         | Retry the same token? | Recommended action        | Worth investigating if frequent                                                                            |
| -------------------------------------- | --------------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------- |
| `refresh_token_revoked`                | No                    | Sign the member in again. | Two processes refreshing the same token at once, or the new refresh token not being saved after a refresh. |
| `refresh_token_expired`                | No                    | Sign the member in again. | Sessions left idle longer than the refresh token lifetime.                                                 |
| `refresh_token_invalid`                | No                    | Sign the member in again. | Tokens that are truncated, corrupted, or mixed up between sandbox and production.                          |
| `refresh_token_client`                 | No                    | Sign the member in again. | Tokens shared across client IDs, for example between web and mobile apps.                                  |
| Absent, or a value you don't recognize | No                    | Sign the member in again. | Nothing new to learn. This is the default behavior.                                                        |

**`Example`**

```javascript Example
const res = await fetch("https://api.id.me/oauth/token", {
  method: "POST",
  body: new URLSearchParams({
    grant_type: "refresh_token",
    client_id: CLIENT_ID,
    client_secret: CLIENT_SECRET,
    refresh_token: storedRefreshToken,
  }),
});

if (res.ok) {
  const tokens = await res.json();
  // Always save the new refresh token. The old one is now used up.
  saveTokens(tokens.access_token, tokens.refresh_token);
} else {
  const err = await res.json();
  if (err.error === "invalid_grant") {
    log("refresh failed", { reason: err.error_reason ?? "none" });
    clearTokens();
    redirectToSignIn();
  } else {
    // invalid_client, invalid_request, and so on: a setup problem, not a member problem
    alertEngineering(err.error);
  }
}
```

> **Best practices**
>
> * Read `error` first, then `error_reason`.
> * Don't require `error_reason` to be present. It can be absent (see [When the field is absent](#when-the-field-is-absent)).
> * Don't parse `error_description`. Its text can change, and it can be translated into the member's language.
> * Don't retry a refresh token after `invalid_grant`.
> * Save the new refresh token after every successful refresh.
> * Ignore JSON fields you don't recognize, so that new fields never break your integration.

## Testing in sandbox

Ask your account team to turn on `error_reason` for your sandbox integration. Sign a test member in through your normal flow to get a token pair, then run these checks against `https://api.idmelabs.com`.

```bash
refresh() {
  curl -s -X POST https://api.idmelabs.com/oauth/token \
    -d grant_type=refresh_token \
    -d client_id="$CLIENT_ID" \
    -d client_secret="$CLIENT_SECRET" \
    -d refresh_token="$1"
}
```

#### Success, then refresh_token_revoked

Run `refresh "$REFRESH_TOKEN"`. It returns new tokens. Run the same command again with the old token. It returns `refresh_token_revoked`.

#### refresh_token_invalid

Run `refresh "not-a-real-token"`.

#### refresh_token_client

Send a refresh token issued to one of your integrations with the `client_id` and `client_secret` of another of your integrations.

#### refresh_token_expired

Keep a refresh token unused until it expires, then refresh with it.

#### No error_reason

Send a wrong `client_secret` (HTTP 401 `invalid_client`) or leave out `refresh_token` (`invalid_request`). Neither response has `error_reason`.

If a check returns `invalid_grant` without `error_reason`, the field may not be turned on yet. Wait a few minutes and try again, then contact your account team.

## FAQ

#### Does error_reason expose anything new about the member?

No. Each value repeats what `error_description` already says, in a form your code can read. IDMe never returns a reason for account-status cases such as a suspended account.

#### Will turning it on break our integration?

No. The HTTP status, `error`, and `error_description` don't change. The only change is one extra JSON field, and only after you ask for it.

#### Does it apply to other grant types or endpoints?

No. It applies only to the refresh token grant on `POST /oauth/token`. Authorization code exchange, the authorization endpoint, and token revocation are unchanged.

#### Can we retry on any of these values?

No. All four mean the refresh token cannot be used again. Retrying only adds failed calls.

## Related

#### [Error codes](/guides/o-auth-2-0/error-codes)

Review every error code the OAuth 2.0 endpoints can return

#### [PKCE](/guides/o-auth-2-0/pkce)

Protect the authorization code flow with Proof Key for Code Exchange