Skip to content

Instantly share code, notes, and snippets.

@ben-vargas
Last active August 26, 2025 22:53
Show Gist options
  • Select an option

  • Save ben-vargas/c7c7cbfebbb47278f45feca9cef309d1 to your computer and use it in GitHub Desktop.

Select an option

Save ben-vargas/c7c7cbfebbb47278f45feca9cef309d1 to your computer and use it in GitHub Desktop.

Implement an Anthropic OAuth CLI and Populate ~/.claude/.credentials.json

This gist shows how to implement your own OAuth workflow so your tool can acquire Anthropic OAuth tokens and write them in the format the Claude Code SDK expects at ~/.claude/.credentials.json.

Notes

  • Uses an Authorization Code + PKCE flow via https://claude.ai/oauth/authorize and token exchange at https://console.anthropic.com/v1/oauth/token.
  • Default client id commonly used by tools: 9d1c250a-e61b-44d9-88ed-5944d1962f5e (override with your own if issued).
  • The authorize endpoint supports a copy/paste mode (no local server). Your CLI opens/prints a URL; the user pastes a code back.
  • Treat tokens as secrets; never log them.

High-level Steps

  1. Generate PKCE verifier/challenge
  2. Build authorize URL (with code=true) and open/print it
  3. Read authorization code (and optional state) from the user
  4. Exchange code for tokens
  5. Write ~/.claude/.credentials.json in Claude Code shape
  6. Implement refresh using refresh_token and keep the file updated

Minimal TypeScript (Node 18+, ESM)

import crypto from 'node:crypto';
import readline from 'node:readline/promises';
import { stdin as input, stdout as output } from 'node:process';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { mkdir, writeFile, chmod } from 'node:fs/promises';

type OAuthTokens = { accessToken: string; refreshToken?: string; expiresAt?: number };

const CLIENT_ID = process.env.ANTHROPIC_CLIENT_ID || '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
const REDIRECT_URI = 'https://console.anthropic.com/oauth/code/callback';
const SCOPES = 'org:create_api_key user:profile user:inference';

function base64url(buf: Buffer) {
  return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

async function generatePkce() {
  const verifier = base64url(crypto.randomBytes(32));
  const challenge = base64url(crypto.createHash('sha256').update(verifier).digest());
  return { verifier, challenge };
}

function buildAuthUrl({ clientId, redirectUri, challenge, state }: { clientId: string; redirectUri: string; challenge: string; state: string; }) {
  const u = new URL('https://claude.ai/oauth/authorize');
  u.searchParams.set('code', 'true'); // copy/paste friendly flow
  u.searchParams.set('client_id', clientId);
  u.searchParams.set('response_type', 'code');
  u.searchParams.set('redirect_uri', redirectUri);
  u.searchParams.set('scope', SCOPES);
  u.searchParams.set('code_challenge', challenge);
  u.searchParams.set('code_challenge_method', 'S256');
  u.searchParams.set('state', state);
  return u.toString();
}

async function exchangeCodeForTokens({ code, state, verifier, clientId, redirectUri }: { code: string; state?: string; verifier: string; clientId: string; redirectUri: string; }) {
  const body = {
    code,
    state: state || verifier,
    grant_type: 'authorization_code',
    client_id: clientId,
    redirect_uri: redirectUri,
    code_verifier: verifier,
  };
  const res = await fetch('https://console.anthropic.com/v1/oauth/token', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`Token exchange failed: HTTP ${res.status}`);
  const json: any = await res.json();
  const expiresAt = Date.now() + Number(json.expires_in ?? 3600) * 1000;
  return { accessToken: json.access_token, refreshToken: json.refresh_token, expiresAt } satisfies OAuthTokens;
}

async function saveClaudeCredentials(tokens: OAuthTokens) {
  const dir = join(homedir(), '.claude');
  const file = join(dir, '.credentials.json');
  await mkdir(dir, { recursive: true });
  const payload = { claudeAiOauth: tokens };
  await writeFile(file, JSON.stringify(payload, null, 2), 'utf8');
  if (process.platform !== 'win32') await chmod(file, 0o600);
  return file;
}

export async function loginInteractive() {
  const rl = readline.createInterface({ input, output });
  try {
    const { verifier, challenge } = await generatePkce();
    const url = buildAuthUrl({ clientId: CLIENT_ID, redirectUri: REDIRECT_URI, challenge, state: verifier });
    console.log('Open this URL in your browser:');
    console.log(url);
    console.log('After authorizing, paste the code here. If shown as CODE#STATE, paste that full string.');
    const codeInput = (await rl.question('Authorization code: ')).trim();
    const [code, state] = codeInput.split('#');
    const tokens = await exchangeCodeForTokens({ code, state, verifier, clientId: CLIENT_ID, redirectUri: REDIRECT_URI });
    const dest = await saveClaudeCredentials(tokens);
    console.log('Saved Claude Code credentials to', dest);
    console.log('Access token ends with:', tokens.accessToken.slice(-6));
  } finally {
    rl.close();
  }
}

Refresh Tokens (recommended)

  • Refresh when near expiry and rewrite the file atomically.
type OAuthTokens = { accessToken: string; refreshToken?: string; expiresAt?: number };
let inFlight: Promise<OAuthTokens> | null = null;

function isExpired(t?: OAuthTokens) {
  if (!t?.expiresAt) return true;
  return Date.now() + 60_000 >= t.expiresAt; // 60s skew
}

async function refreshTokens(current: OAuthTokens, clientId = CLIENT_ID): Promise<OAuthTokens> {
  if (!current.refreshToken) throw new Error('No refresh_token available.');
  const res = await fetch('https://console.anthropic.com/v1/oauth/token', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ grant_type: 'refresh_token', refresh_token: current.refreshToken, client_id: clientId }),
  });
  if (!res.ok) throw new Error(`Refresh failed: HTTP ${res.status}`);
  const json: any = await res.json();
  return {
    accessToken: json.access_token,
    refreshToken: json.refresh_token || current.refreshToken,
    expiresAt: Date.now() + Number(json.expires_in ?? 3600) * 1000,
  };
}

export async function ensureFresh(tokens: OAuthTokens): Promise<OAuthTokens> {
  if (!isExpired(tokens)) return tokens;
  if (!inFlight) inFlight = (async () => refreshTokens(tokens))().finally(() => (inFlight = null));
  const next = await inFlight;
  await saveClaudeCredentials(next);
  return next;
}

CLI Sketch (optional)

  • Wrap the functions above in your CLI (e.g., with Commander/Yargs):
    • login (browser flow with printed URL, no local server needed)
    • show (masked token info, expiry status)
    • revoke (delete ~/.claude/.credentials.json with confirmation)
  • For headless servers, always print the URL and avoid auto-opening a browser.

Security

  • Redact tokens in logs; never print full secrets.
  • Set file permissions to 0600 on POSIX; keep .claude directory private (0700).
  • Update accessToken, refreshToken, and expiresAt together to avoid mismatches.

Compatibility

  • File location: ~/.claude/.credentials.json (use homedir()).
  • File shape required by Claude Code SDK:
    { "claudeAiOauth": { "accessToken": "", "refreshToken": "", "expiresAt": 1756162077244 } }

Troubleshooting

  • If the authorize page shows both code and state, paste as CODE#STATE so you can forward state to the token exchange; if you only get code, pass your PKCE verifier as the state value to the token endpoint.
  • If opening a browser fails in your environment, simply copy the printed URL and open it locally.
  • Handle HTTP 429/5xx on refresh with simple backoff if your tool makes concurrent calls.

Disclaimer

  • Endpoint details and scopes are based on commonly observed usage and may change. Prefer official Anthropic documentation where available and use your own client id if one has been issued to your application.

Implement an Anthropic OAuth CLI and Populate ~/.claude/.credentials.json (Long‑Lived Token Variant)

This gist shows how to implement your own OAuth workflow (without using Claude Code’s built‑in flows) so your tool can acquire a long‑lived Anthropic OAuth token (about 1 year) and write it in the format the Claude Code SDK expects at ~/.claude/.credentials.json.

Compared to a short‑lived flow, the differences are small: you use the same Authorization Code + PKCE flow but request a longer lifetime at the token exchange by including expires_in (e.g., 31536000 seconds). The resulting token is used exactly the same way by Claude Code — sent as a Bearer token. Refresh is typically not needed until close to expiry.

Notes

  • Authorization endpoint: https://claude.ai/oauth/authorize
  • Token endpoint: https://console.anthropic.com/v1/oauth/token
  • Manual/copy‑paste redirect: https://console.anthropic.com/oauth/code/callback
  • Default client id commonly used by tools: 9d1c250a-e61b-44d9-88ed-5944d1962f5e (override with your own if issued)
  • Scopes: prefer least privilege for long‑lived tokens, e.g. user:inference; use broader scopes only if required (org:create_api_key user:profile user:inference).
  • Treat tokens as secrets; never log them.

High‑Level Steps (Long‑Lived)

  1. Generate PKCE verifier/challenge
  2. Build authorize URL (with code=true) and open/print it
  3. Read authorization code (and optional state) from the user
  4. Exchange code for tokens, adding expires_in=31536000 (1 year)
  5. Write ~/.claude/.credentials.json in Claude Code shape
  6. Optionally implement refresh if a refresh_token is returned

Minimal TypeScript (Node 18+, ESM)

import crypto from 'node:crypto';
import readline from 'node:readline/promises';
import { stdin as input, stdout as output } from 'node:process';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { mkdir, writeFile, chmod } from 'node:fs/promises';

type OAuthTokens = { accessToken: string; refreshToken?: string; expiresAt?: number; scopes?: string[] };

const CLIENT_ID = process.env.ANTHROPIC_CLIENT_ID || '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
const REDIRECT_URI = 'https://console.anthropic.com/oauth/code/callback';
// Prefer least-privileged long-lived tokens:
const SCOPES = 'user:inference';
// If you need broader access, use:
// const SCOPES = 'org:create_api_key user:profile user:inference';

const ONE_YEAR_SECONDS = 31536000;

function base64url(buf: Buffer) {
  return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

async function generatePkce() {
  const verifier = base64url(crypto.randomBytes(32));
  const challenge = base64url(crypto.createHash('sha256').update(verifier).digest());
  return { verifier, challenge };
}

function buildAuthUrl({ clientId, redirectUri, challenge, state }: { clientId: string; redirectUri: string; challenge: string; state: string }) {
  const u = new URL('https://claude.ai/oauth/authorize');
  u.searchParams.set('code', 'true'); // copy/paste friendly flow
  u.searchParams.set('client_id', clientId);
  u.searchParams.set('response_type', 'code');
  u.searchParams.set('redirect_uri', redirectUri);
  u.searchParams.set('scope', SCOPES);
  u.searchParams.set('code_challenge', challenge);
  u.searchParams.set('code_challenge_method', 'S256');
  u.searchParams.set('state', state);
  return u.toString();
}

async function exchangeCodeForLongLivedToken({
  code,
  state,
  verifier,
  clientId,
  redirectUri,
  expiresInSec = ONE_YEAR_SECONDS,
}: {
  code: string;
  state?: string;
  verifier: string;
  clientId: string;
  redirectUri: string;
  expiresInSec?: number;
}) {
  const body: Record<string, string | number> = {
    grant_type: 'authorization_code',
    code,
    state: state || verifier, // tolerate CODE#STATE or plain CODE
    client_id: clientId,
    redirect_uri: redirectUri,
    code_verifier: verifier,
    // Long-lived access token request:
    expires_in: expiresInSec,
  };
  const res = await fetch('https://console.anthropic.com/v1/oauth/token', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`Token exchange failed: HTTP ${res.status}`);
  const json: any = await res.json();
  const expiresAt = Date.now() + Number(json.expires_in ?? expiresInSec) * 1000;
  const scopes = typeof json.scope === 'string' ? json.scope.split(' ').filter(Boolean) : undefined;
  return {
    accessToken: json.access_token,
    refreshToken: json.refresh_token, // may be absent for long-lived flows
    expiresAt,
    scopes,
  } satisfies OAuthTokens;
}

async function saveClaudeCredentials(tokens: OAuthTokens) {
  const dir = join(homedir(), '.claude');
  const file = join(dir, '.credentials.json');
  await mkdir(dir, { recursive: true });
  const payload = { claudeAiOauth: tokens };
  await writeFile(file, JSON.stringify(payload, null, 2), 'utf8');
  if (process.platform !== 'win32') await chmod(file, 0o600);
  return file;
}

export async function loginLongLived() {
  const rl = readline.createInterface({ input, output });
  try {
    const { verifier, challenge } = await generatePkce();
    const url = buildAuthUrl({ clientId: CLIENT_ID, redirectUri: REDIRECT_URI, challenge, state: verifier });
    console.log('Open this URL in your browser:');
    console.log(url);
    console.log('After authorizing, paste the code here. If shown as CODE#STATE, paste that full string.');
    const codeInput = (await rl.question('Authorization code: ')).trim();
    const [code, state] = codeInput.split('#');
    const tokens = await exchangeCodeForLongLivedToken({
      code,
      state,
      verifier,
      clientId: CLIENT_ID,
      redirectUri: REDIRECT_URI,
      expiresInSec: ONE_YEAR_SECONDS,
    });
    const dest = await saveClaudeCredentials(tokens);
    console.log('Saved Claude Code credentials to', dest);
    console.log('Access token ends with:', tokens.accessToken.slice(-6));
    const secs = Math.floor(((tokens.expiresAt ?? Date.now()) - Date.now()) / 1000);
    console.log('Requested validity (seconds):', ONE_YEAR_SECONDS, 'Server returned expires_in≈', secs);
  } finally {
    rl.close();
  }
}

Optional Refresh Handling

If the token response includes a refresh_token, you can refresh near expiry just like a short‑lived flow. Otherwise, plan to re‑issue a long‑lived token again before it expires.

type OAuthTokens = { accessToken: string; refreshToken?: string; expiresAt?: number };
let inFlight: Promise<OAuthTokens> | null = null;

function isExpired(t?: OAuthTokens) {
  if (!t?.expiresAt) return true;
  return Date.now() + 60_000 >= t.expiresAt; // 60s skew
}

async function refreshTokens(current: OAuthTokens, clientId = CLIENT_ID): Promise<OAuthTokens> {
  if (!current.refreshToken) throw new Error('No refresh_token available.');
  const res = await fetch('https://console.anthropic.com/v1/oauth/token', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ grant_type: 'refresh_token', refresh_token: current.refreshToken, client_id: clientId }),
  });
  if (!res.ok) throw new Error(`Refresh failed: HTTP ${res.status}`);
  const json: any = await res.json();
  return {
    accessToken: json.access_token,
    refreshToken: json.refresh_token || current.refreshToken,
    expiresAt: Date.now() + Number(json.expires_in ?? 3600) * 1000,
  };
}

export async function ensureFresh(tokens: OAuthTokens): Promise<OAuthTokens> {
  if (!isExpired(tokens)) return tokens;
  if (!inFlight) inFlight = (async () => refreshTokens(tokens))().finally(() => (inFlight = null));
  const next = await inFlight;
  await saveClaudeCredentials(next);
  return next;
}

Credentials File Shape

Claude Code expects ~/.claude/.credentials.json with an OAuth object, e.g.:

{ "claudeAiOauth": { "accessToken": "", "expiresAt": 1756162077244 } }

Security & Caveats

  • Long‑lived tokens require a Claude subscription; the server may cap or ignore your requested expires_in and may restrict scopes.
  • Use the least privilege that fits your use case (e.g., user:inference).
  • Redact tokens in logs; set file permissions to 0600 on POSIX.
  • The Claude Code SDK/CLI will read this file and send the token as Authorization: Bearer …, identical to short‑lived tokens.
Disclaimer: Endpoint details and scope behavior can change. Prefer official Anthropic documentation and your issued client credentials where available.
`
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment