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:
- The CLI POSTs to the device authorization endpoint and receives a
device_code, auser_code, and a verification URL. - The CLI displays the verification URL and user code to the user:
Open https://yourapp.com/device and enter: WXYZ-1234. - The CLI starts polling the token endpoint with the
device_code. - The user opens the URL in any browser, logs in, and enters the code.
- 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);
}
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.