BuildBaseBuildBase

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.

Before you start

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 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.

1. The device asks for a code

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

FieldWhat to send
client_idRequired. In the body, or with the secret in an HTTP Basic header
client_secretRequired unless the client is public (token endpoint auth method none)
scopeOptional. Space-separated, as at /authorize
resourceOptional. The resource indicator the token is for (RFC 8707)
{
  "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 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:

errorWhat to do
authorization_pendingWait interval seconds and poll again
slow_downYou polled too soon. Add 5 seconds to your interval; the answer carries the new interval
access_deniedThe person denied it. Stop
expired_tokenThe 10 minutes ran out, or the code is unknown or already used. Start again from step 1
invalid_grantThe 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

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