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/authorizeand token exchange athttps://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
- Generate PKCE verifier/challenge
- Build authorize URL (with
code=true) and open/print it - Read authorization code (and optional state) from the user
- Exchange code for tokens
- Write
~/.claude/.credentials.jsonin Claude Code shape - Implement refresh using
refresh_tokenand 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.jsonwith 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
0600on POSIX; keep.claudedirectory private (0700). - Update
accessToken,refreshToken, andexpiresAttogether to avoid mismatches.
Compatibility
- File location:
~/.claude/.credentials.json(usehomedir()). - File shape required by Claude Code SDK:
{ "claudeAiOauth": { "accessToken": "…", "refreshToken": "…", "expiresAt": 1756162077244 } }
Troubleshooting
- If the authorize page shows both
codeandstate, paste asCODE#STATEso you can forward state to the token exchange; if you only getcode, pass your PKCE verifier as thestatevalue 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.