# Device code sign-in

Sign in a CLI, a TV app or anything without a browser, with a short code the person approves on their phone or laptop (RFC 8628).

A CLI on a build server cannot open your sign-in page, and nobody wants to type a password with a TV remote. The device code grant (RFC 8628) lets the device borrow another screen. It shows a short code such as `BCDF-GHJK`; the person opens your hosted sign-in page on their phone or laptop, enters the code, signs in and approves; and the device, polling all the while, receives an access token and a refresh token.

Step by step, with screenshots: [Set up device code sign-in](/sign-in-setup/device-code).

> **Note:**
  Use an OAuth2 client, open it with **Edit** in the **Settings** tab, and
  switch on **Device code sign-in (RFC 8628)**, beside **Require PKCE**. It is
  off until you do, and a client without it is answered `unauthorized_client`.


A client created through [dynamic client registration](/authentication/overview#oauth-2-0-authorization-server) gets the grant when its registration lists `urn:ietf:params:oauth:grant-type:device_code` in `grant_types`. The authorization server metadata lists the endpoint and the grant for every app.

The access token comes from your Application Token URL, as for every OAuth2 grant: `handleAppTokenRequest` hands your `mintToken` the claims with `grantType: 'device_code'`, so you can tell a device from a browser sign-in. See [MCP and agents](/reference/mcp-agents#minting-the-agent-s-token).

## 1. The device asks for a code

`POST /api/v1/auth/oauth2/device_authorization?org=<orgId>`, form-encoded:

| Field           | What to send                                                             |
| --------------- | ------------------------------------------------------------------------ |
| `client_id`     | Required. In the body, or with the secret in an HTTP Basic header        |
| `client_secret` | Required unless the client is public (token endpoint auth method `none`) |
| `scope`         | Optional. Space-separated, as at `/authorize`                            |
| `resource`      | Optional. The resource indicator the token is for (RFC 8707)             |

```json
{
  "device_code": "kHn1...",
  "user_code": "BCDF-GHJK",
  "verification_uri": "https://auth.example.com/v1/device?org=6650f1c2a1b2c3d4e5f60718",
  "verification_uri_complete": "https://auth.example.com/v1/device?org=6650f1c2a1b2c3d4e5f60718&user_code=BCDF-GHJK",
  "expires_in": 600,
  "interval": 5
}
```

Show the person `user_code` and `verification_uri`, or `verification_uri_complete` as a link or a QR code, which fills the code in for them. Keep `device_code` to yourself.

For a CLI, prefer a public client. A secret shipped inside a binary anyone can download is not a secret, and the device code flow does not need one.

## 2. The person approves

On the hosted page, the person:

1. enters the code, unless the link filled it in, and checks it matches the one on the device
2. signs in, with every check a sign-in has: a blocked account is refused, and the [second step](/authentication/two-step) is asked for as usual
3. sees **Approve** with the client's name, and a warning to approve only a device they are holding. A client that named itself through dynamic registration is marked as unchecked.

The code is 8 letters from a 20-consonant alphabet: no vowels, so no code spells a word, and nothing that reads as a digit. Case and the dash do not matter when typed. Five wrong codes from one address in 10 minutes earn `429 rate_limited`.

An answer is final. It is written once, so a second tab cannot turn a denial into an approval. For an agent client, approving is consenting: it is recorded as the consent page records it, and your plan's limit on connected agents applies.

## 3. The device polls

`POST /api/v1/auth/oauth2/token?org=<orgId>` with `grant_type=urn:ietf:params:oauth:grant-type:device_code`, the `device_code` and the client's credentials, every `interval` seconds. Until the person approves, each poll gets a `400` with an `error`:

| `error`                 | What to do                                                                                 |
| ----------------------- | ------------------------------------------------------------------------------------------ |
| `authorization_pending` | Wait `interval` seconds and poll again                                                     |
| `slow_down`             | You polled too soon. Add 5 seconds to your interval; the answer carries the new `interval` |
| `access_denied`         | The person denied it. Stop                                                                 |
| `expired_token`         | The 10 minutes ran out, or the code is unknown or already used. Start again from step 1    |
| `invalid_grant`         | The code belongs to another client, or the person was blocked after approving. Stop        |

Once they approve, the next poll answers `access_token`, `token_type`, `expires_in`, `refresh_token`, `scope` and the person's `name` and `email`. The device code is spent by that answer.

## A CLI that polls correctly

```typescript
const SERVER = 'https://api.console.buildbase.app';
const ORG_ID = process.env.BUILDBASE_ORG_ID!;
const CLIENT_ID = process.env.BUILDBASE_CLIENT_ID!; // a public client

async function post(path: string, body: Record<string, string>) {
  const res = await fetch(
    `${SERVER}/api/v1/auth/oauth2/${path}?org=${ORG_ID}`,
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams(body),
    }
  );
  return { ok: res.ok, data: await res.json() };
}

const sleep = (seconds: number) =>
  new Promise((resolve) => setTimeout(resolve, seconds * 1000));

export async function signIn() {
  const start = await post('device_authorization', { client_id: CLIENT_ID });
  if (!start.ok) throw new Error(start.data.error_description);

  const { device_code, user_code, verification_uri_complete } = start.data;
  let interval: number = start.data.interval;
  console.log(`Open ${verification_uri_complete}`);
  console.log(`and check it shows ${user_code}.`);

  const deadline = Date.now() + start.data.expires_in * 1000;
  while (Date.now() < deadline) {
    await sleep(interval);
    const poll = await post('token', {
      grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
      device_code,
      client_id: CLIENT_ID,
    });
    if (poll.ok) return poll.data; // access_token, refresh_token, ...

    if (poll.data.error === 'authorization_pending') continue;
    if (poll.data.error === 'slow_down') {
      interval = poll.data.interval ?? interval + 5;
      continue;
    }
    // access_denied, expired_token or invalid_grant: stop and say why.
    throw new Error(poll.data.error_description ?? poll.data.error);
  }
  throw new Error('The code expired before it was approved.');
}
```

It waits a full interval before the first poll, too. A poll inside the interval is answered `slow_down` and pushes the interval out by 5 seconds, so a loop that polls as fast as it can only waits longer.

## Next steps

- [MCP and agents](/reference/mcp-agents) - the Application Token URL that mints the device's token.
- [Overview](/authentication/overview#oauth-2-0-authorization-server) - the rest of the OAuth 2.0 authorization server.
- [Sign In & Sign Out](/authentication/sign-in-sign-out) - sign-in for apps that do have a browser.
