<!-- llms.txt: https://workos.com/llms.txt -->
<!-- feedback.md: https://workos.com/docs/feedback.md -->

# CLI Auth

## Introduction

CLI Auth enables your command-line applications to authenticate users through the web via your WorkOS app. Based on the [OAuth 2.0 Device Authorization Flow](https://datatracker.ietf.org/doc/html/rfc8628), this flow is optimized for devices that lack a web browser or have limited input capabilities.

With CLI Auth, your command-line app requests a device authorization from WorkOS, which includes a code for the user and a code for your app. After the user confirms the code, your app can exchange its device code for tokens.

Despite the name, CLI Auth works for any public client that cannot reliably receive a browser redirect, including native mobile, TV, and embedded applications.

## (1) Request device authorization

To begin the authentication flow, your CLI application makes a request to the `/authorize/device` endpoint to obtain the necessary codes and URLs for user authentication.

:::code-group

```js language="js" title="Request" tab="1"
const response = await fetch(
  'https://api.workos.com/user_management/authorize/device',
  {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
    },
    body: new URLSearchParams({
      client_id: 'client_123456789',
    }),
  },
);

const data = await response.json();
```

```json language="json" title="Response" tab="2"
{
  "device_code": "71azDp28ToiCscGDvLxnXLkuuFRMrnd4V7rdsjIlBPXuy13j8GOzU0aZHb46tsz3",
  "user_code": "RRGQ-BJVS",
  "verification_uri": "https://authkit_domain/device",
  "verification_uri_complete": "https://authkit_domain/device?user_code=ABCD-EFGH",
  "expires_in": 300,
  "interval": 5
}
```

:::

After you get a response, your app can provide next steps to the user.

![Screenshot of a command-line application showing login information](https://images.workoscdn.com/images/89680848-5c40-4ba1-860f-a7212cfbb47b.png?auto=format\&fit=clip\&q=50)

Your application should display the `user_code` from the response, along with the `verification_uri` in the terminal. If you offer the ability to open in a browser easily like in this screenshot, we suggest using the `verification_uri_complete` for that.

Never display the `device_code` to the user. That is only for the device to poll the token endpoint.

## (2) User confirms the code

Next the user needs to confirm the code in their browser.

### (A) Manual code entry

If the user navigates to the `verification_uri`, they'll be presented with a form to enter the code manually. If they are not logged in they'll be prompted to do that first and then returned to the code entry screen.

![Screenshot of the manual-code-entry form in AuthKit](https://images.workoscdn.com/images/03c1961a-547a-4886-bb9a-2f080dd31ca9.png?auto=format\&fit=clip\&q=50)

### (B) One-click confirmation

If the user goes to the `verification_uri_complete`, (for example, `https://<authkit_domain>/device?user_code=ABCD-EFGH`, they'll instead need to confirm that the code matches what is displayed in the terminal.

![Screenshot of the pre-filled code-confirmation form in AuthKit](https://images.workoscdn.com/images/3e02f36c-0dea-4ff4-9dc5-a2f2fa964114.png?auto=format\&fit=clip\&q=50)

## (3) Request tokens

While the user is completing authentication in their browser, your CLI application should poll the token endpoint to check for authorization completion.

Make requests to the token endpoint using `device_code` from the authorization response from step 1:

:::code-group

```js language="js" title="Request" tab="1"
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

async function pollForTokens({
  clientId,
  deviceCode,
  expiresIn = 300,
  interval = 5,
}) {
  const timeout = AbortSignal.timeout(expiresIn * 1000);

  while (true) {
    const response = await (async () => {
      try {
        return await fetch(
          'https://api.workos.com/user_management/authenticate',
          {
            method: 'POST',
            headers: {
              'Content-Type': 'application/x-www-form-urlencoded',
            },
            body: new URLSearchParams({
              grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
              device_code: deviceCode,
              client_id: clientId,
            }),
            signal: timeout,
          },
        );
      } catch (error) {
        if (error.name === 'TimeoutError') {
          throw new Error('Authorization timed out');
        }
        throw error;
      }
    })();

    const data = await response.json();

    if (response.ok) {
      // Success! Return tokens for further processing.
      return data;
    }

    switch (data.error) {
      case 'authorization_pending':
        // Wait before polling again
        await sleep(interval * 1000);
        break;

      case 'slow_down':
        // You shouldn't see this if you're only polling every 5 seconds.
        // In this example, we're increasing the interval by 1 second when this happens.
        interval += 1;
        await sleep(interval * 1000);
        break;

      // Terminal cases
      case 'access_denied':
      case 'expired_token':
        throw new Error('Authorization failed');

      default:
        throw new Error('Authorization failed');
    }
  }
}
```

```json language="json" title="Response" tab="2"
{
  "user": {
    "object": "user",
    "id": "user_01JYHX0DW7077GPTAY8MZVNMQX",
    "email": "grant.mccode@workos.com",
    "email_verified": true,
    "first_name": "Grant",
    "last_name": "McCode",
    "profile_picture_url": null,
    "last_sign_in_at": "2025-06-25T19:16:35.647Z",
    "created_at": "2025-06-25T01:20:21.355Z",
    "updated_at": "2025-06-25T19:16:35.647Z",
    "external_id": null
  },
  "organization_id": "org_01JYHNPKWTD5DRGPJHNYBB1HB8",
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkdyYW50IE1jQ29kZSIsImFkbWluIjp0cnVlLCJpYXQiOjEzMzcsInBhc3N3b3JkIjoiaHVudGVyMiJ9.kcmTbx7M89k-3qUXN1UVcy9us6xdPZkDOqQ0UeY3Bws",
  "refresh_token": "RSzR4ngmJROKFJZQEpp5fNF4y",
  "authentication_method": "GoogleOAuth"
}
```

:::

### Polling best practices

- Poll at the interval specified in the authorization response (every 5 seconds)
- Respect `slow_down` errors by increasing your polling interval
- Stop polling when you receive `access_denied` or `expired_token` errors
- Implement a reasonable timeout to avoid infinite polling

## (4) Refresh tokens

The token response includes a `refresh_token`. Store it securely and exchange it at the same authentication endpoint when the access token expires. Public device clients send the client ID but do not embed an API key or client secret.

:::code-group

```js language="js" title="Request" tab="1"
const response = await fetch(
  'https://api.workos.com/user_management/authenticate',
  {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'refresh_token',
      refresh_token: refreshToken,
      client_id: clientId,
    }),
  },
);

const result = await response.json().catch(() => ({}));

if (!response.ok) {
  throw Object.assign(
    new Error(
      result.error_description ??
        `Unable to refresh session (${response.status})`,
    ),
    { code: result.error, status: response.status },
  );
}

const tokens = result;
```

```json language="json" title="Response" tab="2"
{
  "user": {
    "object": "user",
    "id": "user_01JYHX0DW7077GPTAY8MZVNMQX",
    "email": "grant.mccode@workos.com",
    "email_verified": true,
    "first_name": "Grant",
    "last_name": "McCode",
    "profile_picture_url": null,
    "last_sign_in_at": "2025-06-25T19:16:35.647Z",
    "created_at": "2025-06-25T01:20:21.355Z",
    "updated_at": "2025-06-25T19:16:35.647Z",
    "external_id": null
  },
  "organization_id": "org_01JYHNPKWTD5DRGPJHNYBB1HB8",
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkdyYW50IE1jQ29kZSIsImFkbWluIjp0cnVlLCJpYXQiOjEzMzcsInBhc3N3b3JkIjoiaHVudGVyMiJ9.kcmTbx7M89k-3qUXN1UVcy9us6xdPZkDOqQ0UeY3Bws",
  "refresh_token": "RSzR4ngmJROKFJZQEpp5fNF4y",
  "authentication_method": "GoogleOAuth"
}
```

:::

The response contains a new access token and refresh token. Replace the stored refresh token after every successful exchange. If WorkOS returns `invalid_grant`, discard the tokens and restart device authorization; retry transient server or network failures with backoff.

For Connect applications, exchange the refresh token at `https://<authkit_domain>/oauth2/token` instead. See the [Connect refresh token grant](https://workos.com/docs/reference/workos-connect/token/refresh-token-grant) for the request and response contract. Public Connect applications omit `client_secret`.

## Connect

CLI Auth is available for [Connect](https://workos.com/docs/authkit/connect/oauth) applications, allowing you and third-party developers to build CLI tools that integrate with your app's credentials.

The flow is the same but uses Connect endpoints:

- **Authorization**: `https://<authkit_domain>/oauth2/device_authorization`
- **Token**: `https://<authkit_domain>/oauth2/token`

Since command-line applications are distributed to end users, you should avoid embedding the client secret in the app. To make this work, set up your Connect app as a [*Public* application](https://workos.com/docs/authkit/connect/oauth/public-applications).

Third-party Connect applications will require users to [grant consent](https://workos.com/docs/authkit/connect/oauth/first-party-vs-third-party-applications) to the third-party app.
