---
title: Server-side flow
description: The confidential-client OAuth 2.0 walkthrough for Sign in with Insy — authorize, exchange the code for tokens, call a resource endpoint, and refresh.
---

This is the flow for a confidential client: an application with a backend that can keep
`client_secret` out of the browser. You send the user to Insy, receive a short-lived
authorization code on your redirect URI, and exchange that code for tokens from your server.

If your application cannot hold a secret — a single-page app, a mobile app, a browser widget —
use a public client with PKCE instead. See [PKCE and public clients](/developers/oauth/pkce).

> **Two things that break off-the-shelf OAuth libraries**
>
> `POST /oauth/token` answers with **HTTP 201**, not the RFC-mandated 200. And Insy does **not**
> support HTTP Basic authentication (`client_secret_basic`) — credentials go in the request body.
> Configure your library before you write any other code.

## Register your client

You register the client yourself at
[Account → Developer → OAuth clients](https://insy.io/account/developer/oauth-clients). You supply
an application name, your redirect URIs and the scopes you need; you receive a `client_id` (format
`oac_` plus 24 hex characters) and
a `client_secret` (format `ocs_` plus 48 hex characters). The secret is shown once — store it in
your secret manager, never in a repository or a client bundle.

Only redirect URIs registered up front are accepted. An unregistered `redirect_uri` is rejected
with an HTTP 400 rather than redirected, so register every environment you intend to run in.

1. **Build the authorization URL**

    Send the user's browser to `https://api.insy.io/oauth/authorize` with the parameters below. This
    endpoint is browser-facing: it always answers with a 302, or a 400 in the cases where it refuses
    to redirect.

    | Parameter | Required | Description |
    | --- | --- | --- |
    | `response_type` | No | Accepted but not enforced — the server never validates it and always treats the flow as Authorization Code. Send `code` for spec compliance. |
    | `client_id` | Yes | Your client identifier, `oac_` plus 24 hex characters. |
    | `redirect_uri` | Yes | Must exactly match one of the redirect URIs registered for the client. |
    | `scope` | Yes | Space- or comma-separated list. Currently `memberships.read` and `products.read`. |
    | `state` | No | Opaque value echoed back on the redirect, max 1024 characters. Strongly recommended — it is your CSRF defence. |
    | `code_challenge` | Public clients | Base64url SHA-256 of your `code_verifier`. Optional for confidential clients. |
    | `code_challenge_method` | No | Optional — `S256` is assumed whenever a `code_challenge` is present. Only `S256` is accepted; no other method is supported. |

    > **scope is required**
    >
    > RFC 6749 makes `scope` optional. Insy requires it. Omitting it fails the request, so always set
    > it explicitly even when you only need one scope.

    ```js title="Build the URL (Node.js)"
    import { randomBytes } from "node:crypto";

    const state = randomBytes(16).toString("hex");
    // Persist `state` against the user's session — you compare it on the way back.
    session.oauthState = state;

    const authorizeUrl = new URL("https://api.insy.io/oauth/authorize");
    authorizeUrl.searchParams.set("response_type", "code");
    authorizeUrl.searchParams.set("client_id", process.env.INSY_CLIENT_ID);
    authorizeUrl.searchParams.set(
      "redirect_uri",
      "https://app.example.com/auth/insy/callback",
    );
    authorizeUrl.searchParams.set("scope", "memberships.read products.read");
    authorizeUrl.searchParams.set("state", state);

    response.redirect(authorizeUrl.toString());
    ```

    The resulting URL looks like this:

    ```text title="Authorization request"
    https://api.insy.io/oauth/authorize
      ?response_type=code
      &client_id=oac_9f2c41ab77e05d3612b8c4ef
      &redirect_uri=https%3A%2F%2Fapp.example.com%2Fauth%2Finsy%2Fcallback
      &scope=memberships.read%20products.read
      &state=7b1e4c9a2f6d8035
    ```

2. **Handle the redirect and verify state**

    After the user signs in and approves the request, Insy redirects the browser back to your
    `redirect_uri` with `code` and `state` in the query string:

    ```text title="Success redirect"
    https://app.example.com/auth/insy/callback?code=c4f81a30d97b6e25f0a3c8d17b492e6a&state=7b1e4c9a2f6d8035
    ```

    If the user declines, you get an error instead of a code — with `state` still echoed back:

    ```text title="User declined"
    https://app.example.com/auth/insy/callback?error=access_denied&state=7b1e4c9a2f6d8035
    ```

    An unknown scope comes back the same way, as `error=invalid_scope`. Two failures are **not**
    redirected at all, because Insy cannot trust the destination: an unknown `client_id` and an
    unregistered `redirect_uri` both return an HTTP 400 with a JSON body. If you are staring at a
    400 in the browser, check those two values first.

    ```js title="Callback handler (Node.js)"
    app.get("/auth/insy/callback", async (request, response) => {
      const { code, state, error } = request.query;

      // Constant-time-ish comparison is unnecessary here, but the check is not:
      // an unsolicited callback with no matching state is a CSRF attempt.
      if (!state || state !== request.session.oauthState) {
        return response.status(400).send("State mismatch");
      }
      delete request.session.oauthState;

      if (error) {
        // The redirect carries `error` and `state` only — never an error_description.
        return response.status(400).send(`Insy returned ${error}`);
      }

      const tokens = await exchangeCode(code);
      // ... persist the tokens against your own user record
    });
    ```

    > **The code expires in 60 seconds**
    >
    > An authorization code is valid for 60 seconds and is single-use. Exchange it in the callback
    > handler, not on a queue.

3. **Exchange the code for tokens**

    `POST /oauth/token` accepts either `application/json` or
    `application/x-www-form-urlencoded`. Send `client_id` and `client_secret` in the **body**.

    | Field | Required | Description |
    | --- | --- | --- |
    | `grant_type` | Yes | `authorization_code` for this step. |
    | `client_id` | Yes | Your client identifier. |
    | `client_secret` | Confidential clients | Required for confidential clients. Public clients must omit it. |
    | `code` | Yes | The code from the redirect. |
    | `redirect_uri` | Yes | The same value you sent to `/oauth/authorize`. |
    | `code_verifier` | With PKCE | Required whenever the authorization request carried a `code_challenge`. |

    <CodeGroup>

    ```bash title="curl"
    curl -i -X POST https://api.insy.io/oauth/token \
      -H "Content-Type: application/json" \
      -d '{
        "grant_type": "authorization_code",
        "client_id": "oac_9f2c41ab77e05d3612b8c4ef",
        "client_secret": "ocs_3b71e0d9c85a24f6b0d78e13a45c9f27e6b18d04a7c35f92",
        "code": "c4f81a30d97b6e25f0a3c8d17b492e6a",
        "redirect_uri": "https://app.example.com/auth/insy/callback"
      }'
    ```

    ```js title="Node.js"
    async function exchangeCode(code) {
      const res = await fetch("https://api.insy.io/oauth/token", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          grant_type: "authorization_code",
          client_id: process.env.INSY_CLIENT_ID,
          client_secret: process.env.INSY_CLIENT_SECRET,
          code,
          redirect_uri: "https://app.example.com/auth/insy/callback",
        }),
      });

      // The success status is 201. Test for any 2xx — never for `res.status === 200`.
      if (!res.ok) {
        const body = await res.json();
        throw new Error(`${body.error}: ${body.error_description}`);
      }

      return res.json();
    }
    ```

    </CodeGroup>

    A successful exchange returns **HTTP 201** and this body:

    ```json title="201 Created"
    {
      "access_token": "a1b7c3d9e5f20486b3c1d7e9f5a20486b3c1d7e9",
      "refresh_token": "5e2c9a71d84f0b36c7a1e93f5b820d4ec6a37f18",
      "token_type": "Bearer",
      "expires_in": 3600,
      "scope": "memberships.read products.read"
    }
    ```

    > **201, not 200**
    >
    > Strict OAuth client libraries assert a 200 on the token endpoint and throw on anything else.
    > If your library exposes a way to widen the accepted status codes, set it to accept any 2xx. If it
    > does not, perform the token request with a plain HTTP client — it is a single POST with a JSON
    > body.

    > **client_secret_basic is not supported**
    >
    > Most OAuth libraries default to `client_secret_basic`, which sends the credentials in an
    > `Authorization: Basic` header. Insy does not read that header. Depending on what your library
    > still puts in the body, the request fails with `invalid_request` (HTTP 400, because `client_id`
    > never arrived) or `invalid_client` (HTTP 401, because the secret never arrived). Configure the
    > client for **`client_secret_post`** so both credentials go in the body.

    Errors come back as a JSON object with `error` and `error_description`. `invalid_client` is
    returned with HTTP 401; every other token error is HTTP 400:

    ```json title="400 Bad Request"
    {
      "error": "invalid_grant",
      "error_description": "Authorization code is invalid or expired"
    }
    ```

    Tokens are opaque random strings. Do not attempt to decode or parse them — there is no JWT
    payload, no `id_token` and no OpenID Connect discovery document behind them.

4. **Call a resource endpoint**

    Send the access token as a bearer token. Start with `GET /oauth/userinfo`, which requires no
    scope and gives you the stable identifier to key your own records on.

    <CodeGroup>

    ```bash title="curl"
    curl https://api.insy.io/oauth/userinfo \
      -H "Authorization: Bearer a1b7c3d9e5f20486b3c1d7e9f5a20486b3c1d7e9"
    ```

    ```js title="Node.js"
    const res = await fetch("https://api.insy.io/oauth/userinfo", {
      headers: { Authorization: `Bearer ${accessToken}` },
    });
    const profile = await res.json();
    ```

    </CodeGroup>

    ```json title="200 OK"
    {
      "sub": "3f6b1c88-5d2e-4a71-9c40-8b71e2f0a5d3",
      "email": "maja@example.com"
    }
    ```

    `sub` is the Insy user UUID and is stable — store it. `email` can be `null`, so never use it as
    your primary key.

    With the `memberships.read` scope you can then read the user's active memberships:

    ```bash title="curl"
    curl https://api.insy.io/oauth/memberships \
      -H "Authorization: Bearer a1b7c3d9e5f20486b3c1d7e9f5a20486b3c1d7e9"
    ```

    Resource endpoints return bare JSON, not the `success` / `data` envelope used elsewhere in the
    API. On failure you get `invalid_token` with HTTP 401, or `insufficient_scope` with HTTP 403.
    No `WWW-Authenticate` header is sent, so branch on the status code and the `error` field.

    See the [endpoint reference](/developers/oauth/reference) for every response shape.

5. **Refresh before the access token expires**

    An access token lives for one hour (`expires_in: 3600`). A refresh token lives for 30 days on a
    sliding window: each refresh issues a new pair and a fresh 30-day window, so a user who returns
    at least once a month never has to sign in again.

    <CodeGroup>

    ```bash title="curl"
    curl -i -X POST https://api.insy.io/oauth/token \
      -H "Content-Type: application/json" \
      -d '{
        "grant_type": "refresh_token",
        "client_id": "oac_9f2c41ab77e05d3612b8c4ef",
        "client_secret": "ocs_3b71e0d9c85a24f6b0d78e13a45c9f27e6b18d04a7c35f92",
        "refresh_token": "5e2c9a71d84f0b36c7a1e93f5b820d4ec6a37f18"
      }'
    ```

    ```js title="Node.js"
    // Refresh a minute early so an in-flight request never races the expiry.
    const REFRESH_MARGIN_MS = 60_000;

    async function getAccessToken(account) {
      if (Date.now() < account.expiresAt - REFRESH_MARGIN_MS) {
        return account.accessToken;
      }

      const res = await fetch("https://api.insy.io/oauth/token", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          grant_type: "refresh_token",
          client_id: process.env.INSY_CLIENT_ID,
          client_secret: process.env.INSY_CLIENT_SECRET,
          refresh_token: account.refreshToken,
        }),
      });

      if (!res.ok) {
        const body = await res.json();
        // invalid_grant here means the refresh token is spent, revoked or 30 days old.
        throw new Error(`${body.error}: ${body.error_description}`);
      }

      const tokens = await res.json();
      await saveTokens(account.id, {
        accessToken: tokens.access_token,
        refreshToken: tokens.refresh_token,
        expiresAt: Date.now() + tokens.expires_in * 1000,
      });
      return tokens.access_token;
    }
    ```

    </CodeGroup>

    The response is the same 201 and the same body shape as the code exchange. Always persist the new
    `refresh_token` — the old one stops working the moment the new pair is issued.

    > **Refreshing invalidates the old access token too**
    >
    > A refresh revokes the previous refresh token **and** the previous access token immediately. The
    > old access token does not keep working until its hour is up.
    >
    > If several processes share one stored refresh token, they will knock each other offline: server A
    > refreshes, server B's access token dies mid-request, server B refreshes with a token that server A
    > already spent and gets `invalid_grant`. Serialise it — a single refresh path behind a lock or a
    > mutex on the shared token store, with the other processes reading the result. There is no refresh
    > token reuse detection to fall back on, so the failure looks like random 401s rather than a clear
    > security event.

## Lifetimes at a glance

| Credential | Lifetime | Notes |
| --- | --- | --- |
| Authorization code | 60 seconds | Single use. Exchange it in the callback handler. |
| Access token | 1 hour (`expires_in: 3600`) | Revoked early by a refresh or by `POST /oauth/revoke`. |
| Refresh token | 30 days, sliding | Each refresh mints a new pair with a fresh 30-day window and revokes the old pair. |

## Signing the user out

To drop a token deliberately, call `POST /oauth/revoke` with your client credentials and the
token. It returns HTTP 200 with an empty body whether or not the token existed, so treat a 200 as
"the token is gone" rather than as confirmation that it was ever valid.

```bash title="curl"
curl -X POST https://api.insy.io/oauth/revoke \
  -H "Content-Type: application/json" \
  -d '{
    "client_id": "oac_9f2c41ab77e05d3612b8c4ef",
    "client_secret": "ocs_3b71e0d9c85a24f6b0d78e13a45c9f27e6b18d04a7c35f92",
    "token": "5e2c9a71d84f0b36c7a1e93f5b820d4ec6a37f18",
    "token_type_hint": "refresh_token"
  }'
```

## Next

<CardGroup cols={2}>
  <Card title="PKCE and public clients" href="/developers/oauth/pkce" icon="shield-check">
    For SPAs, mobile apps and anything that cannot hold a secret.
  </Card>
  <Card title="Endpoint reference" href="/developers/oauth/reference" icon="list">
    Every parameter, response and error code in one place.
  </Card>
</CardGroup>
