Auth for developer CLIs: device flow, PAT tokens, and OAuth in the terminal

A CLI tool that calls your API needs a way for users to authenticate without a browser redirect going nowhere useful. The loopback redirect trick (redirect to http://localhost:PORT/callback) works for native desktop apps but is clumsy for headless servers and SSH sessions. Device authorization flow — the OAuth grant specifically designed for input-constrained devices — is the correct solution for interactive CLI auth. Personal Access Tokens are the correct solution for CI/CD. Understanding when to use each, and how to store credentials safely, separates a polished developer tool from one that gets your users' tokens written to dotfiles in plaintext.

Device authorization flow

RFC 8628 defines the device authorization grant. The flow does not require the CLI to receive a redirect. Instead:

  1. The CLI POSTs to the device authorization endpoint and receives a device_code, a user_code, and a verification URL.
  2. The CLI displays the verification URL and user code to the user: Open https://yourapp.com/device and enter: WXYZ-1234.
  3. The CLI starts polling the token endpoint with the device_code.
  4. The user opens the URL in any browser, logs in, and enters the code.
  5. The next poll after authorization returns the access and refresh tokens.
// CLI device flow implementation (Node.js)
import open from 'open';  // optional: attempt to open browser automatically

async function loginWithDeviceFlow(clientId, authServerBase) {
  // Step 1: Request device authorization
  const deviceResp = await fetch(`${authServerBase}/oauth/device/authorize`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      client_id: clientId,
      scope: 'openid profile offline_access',
    }),
  });
  const device = await deviceResp.json();
  // device = { device_code, user_code, verification_uri, expires_in, interval }

  console.log(`\nOpen this URL in your browser:`);
  console.log(`  ${device.verification_uri_complete || device.verification_uri}`);
  console.log(`\nOr enter this code at ${device.verification_uri}:`);
  console.log(`  ${device.user_code}\n`);

  // Attempt to open browser (ignore failure on headless systems)
  try { await open(device.verification_uri_complete); } catch {}

  // Step 2: Poll for token
  const interval = (device.interval || 5) * 1000;
  const deadline = Date.now() + device.expires_in * 1000;

  while (Date.now() < deadline) {
    await new Promise(r => setTimeout(r, interval));

    const tokenResp = await fetch(`${authServerBase}/oauth/token`, {
      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: device.device_code,
        client_id: clientId,
      }),
    });

    const tokenData = await tokenResp.json();

    if (tokenResp.ok) {
      return tokenData;  // { access_token, refresh_token, id_token, expires_in }
    }

    if (tokenData.error === 'slow_down') {
      // Server asking us to back off — increase interval
      interval += 5000;
      continue;
    }

    if (tokenData.error !== 'authorization_pending') {
      throw new Error(`Auth failed: ${tokenData.error}`);
    }
  }

  throw new Error('Device flow timed out');
}

Storing tokens in the system keychain

After a successful device flow, the tokens need to be persisted so the user does not log in every time. Writing tokens to a dotfile in plaintext is a common anti-pattern — any process that can read ~/.yourapp/config.json can steal the token. The system keychain is the correct storage: it is encrypted, access-controlled to the user account, and uses OS-level security APIs.

// Using 'keytar' for cross-platform keychain access
// macOS: Keychain, Windows: Credential Manager, Linux: libsecret
import keytar from 'keytar';

const SERVICE = 'yourapp-cli';
const ACCOUNT_ACCESS = 'access_token';
const ACCOUNT_REFRESH = 'refresh_token';

async function saveTokens(tokens) {
  await keytar.setPassword(SERVICE, ACCOUNT_ACCESS, tokens.access_token);
  if (tokens.refresh_token) {
    await keytar.setPassword(SERVICE, ACCOUNT_REFRESH, tokens.refresh_token);
  }
}

async function loadTokens() {
  const accessToken = await keytar.getPassword(SERVICE, ACCOUNT_ACCESS);
  const refreshToken = await keytar.getPassword(SERVICE, ACCOUNT_REFRESH);
  return { accessToken, refreshToken };
}

async function clearTokens() {
  await keytar.deletePassword(SERVICE, ACCOUNT_ACCESS);
  await keytar.deletePassword(SERVICE, ACCOUNT_REFRESH);
}
On Linux, keytar requires libsecret to be installed (libsecret-1-dev on Debian/Ubuntu, libsecret-devel on Fedora). On headless Linux servers without a desktop session, the secret service daemon is typically not running. Fall back to a permissions-restricted config file (chmod 600) with a warning to the user that secure storage is unavailable.

Personal Access Tokens for CI/CD

Device flow is interactive — it requires a human to complete authorization in a browser. CI/CD pipelines cannot do that. The solution is Personal Access Tokens (PATs): long-lived, scoped tokens that users generate through the web dashboard and inject as environment variables into their CI system.

# Server-side PAT management schema
CREATE TABLE personal_access_tokens (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id     UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  name        TEXT NOT NULL,                    -- human label: "GitHub Actions - prod"
  token_hash  TEXT NOT NULL UNIQUE,             -- SHA-256 of the actual token
  token_prefix TEXT NOT NULL,                   -- first 8 chars for display: "bst_A3kz..."
  scopes      TEXT[] NOT NULL,
  last_used_at TIMESTAMPTZ,
  expires_at  TIMESTAMPTZ,                      -- NULL = no expiry (discouraged)
  created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  revoked_at  TIMESTAMPTZ
);
import crypto from 'crypto';

// Generate PAT — shown to user ONCE at creation
function generatePAT() {
  const prefix = 'bst_';  // recognizable prefix for secret scanning tools
  const random = crypto.randomBytes(32).toString('base64url');
  return `${prefix}${random}`;
}

// Store only the hash, never the plaintext token
async function createPAT(userId, name, scopes, expiresAt) {
  const token = generatePAT();
  const hash = crypto.createHash('sha256').update(token).digest('hex');
  const prefix = token.substring(0, 12);

  await db.query(
    `INSERT INTO personal_access_tokens
     (user_id, name, token_hash, token_prefix, scopes, expires_at)
     VALUES ($1, $2, $3, $4, $5, $6)`,
    [userId, name, hash, prefix, scopes, expiresAt]
  );

  return token;  // return plaintext once — not stored
}

// Verify PAT on API requests
async function verifyPAT(rawToken) {
  const hash = crypto.createHash('sha256').update(rawToken).digest('hex');

  const result = await db.query(
    `SELECT * FROM personal_access_tokens
     WHERE token_hash = $1
       AND revoked_at IS NULL
       AND (expires_at IS NULL OR expires_at > NOW())`,
    [hash]
  );

  if (!result.rows[0]) return null;

  // Update last_used_at (fire-and-forget)
  db.query('UPDATE personal_access_tokens SET last_used_at = NOW() WHERE id = $1',
    [result.rows[0].id]);

  return result.rows[0];
}

Recognizable token prefixes

Adding a recognizable prefix like bst_ or ghp_ enables automated secret scanning. GitHub, GitLab, and many security tools scan for these patterns in code commits and public repositories. If a developer accidentally commits a PAT, the scanning system can alert you to revoke it automatically. Register your prefix with GitHub's secret scanning partner program to get automated revocation notifications.

Refresh token handling in the CLI

Access tokens expire. The CLI should transparently refresh them using the stored refresh token without prompting the user to log in again.

async function getValidAccessToken() {
  const { accessToken, refreshToken } = await loadTokens();

  // Check if access token is still valid (with 60s buffer)
  if (accessToken) {
    const payload = JSON.parse(
      Buffer.from(accessToken.split('.')[1], 'base64url').toString()
    );
    if (payload.exp > Math.floor(Date.now() / 1000) + 60) {
      return accessToken;
    }
  }

  if (!refreshToken) {
    throw new Error('Not logged in. Run: yourapp login');
  }

  // Refresh
  const resp = await fetch(`${AUTH_SERVER}/oauth/token`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'refresh_token',
      refresh_token: refreshToken,
      client_id: CLIENT_ID,
    }),
  });

  if (!resp.ok) {
    await clearTokens();
    throw new Error('Session expired. Run: yourapp login');
  }

  const tokens = await resp.json();
  await saveTokens(tokens);
  return tokens.access_token;
}

For non-interactive environments where neither device flow nor PATs are available, consider OIDC Workload Identity Federation — a mechanism where a CI provider (GitHub Actions, GitLab CI) issues a short-lived OIDC token that can be exchanged for an access token without any stored credentials. This eliminates long-lived secrets from CI environments entirely.

← Back to blog Try Bastionary free →