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:
| 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) |
{
"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:
- enters the code, unless the link filled it in, and checks it matches the one on the device
- signs in, with every check a sign-in has: a blocked account is refused, and the second step is asked for as usual
- 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
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 - the Application Token URL that mints the device's token.
- Overview - the rest of the OAuth 2.0 authorization server.
- Sign In & Sign Out - sign-in for apps that do have a browser.