$ if [ -n "$ANTHROPIC_API_KEY" ]; then echo "SET"; else echo "NOT SET"; fi
NOT SET
As expected — Claude Pro users authenticate via OAuth, not API keys.
A complete guide to discovering how Claude Code authenticates with Anthropic's API, reads rate-limit usage, and refreshes OAuth tokens — all from first principles.
Claude Code (Anthropic's CLI tool) shows a usage status line that tells you how much of your Claude Pro/Max rate limit you've consumed — things like "resets at 9pm" or utilization percentages. The goal was to:
curl commandANTHROPIC_API_KEY). They authenticate via OAuth through Claude.ai. The OAuth flow, endpoints, and required headers are not publicly documented.
Before diving into the commands, here's how the pieces fit together:
┌──────────────┐ ┌───────────────────┐ ┌──────────────────────┐ │ Claude Code │ │ macOS Keychain │ │ api.anthropic.com │ └──────┬───────┘ └─────────┬───────────┘ └──────────┬───────────┘ │ │ │ │ ──── store tokens ────▶ │ │ │ │ │ │ ◀── read tokens ────── │ │ │ │ │ │ POST /v1/messages │ │ Authorization: Bearer {token} │ │ anthropic-beta: oauth-2025-04-20 ← THE SECRET SAUCE │ │ ─────────────────────────────────────────────────────▶ │ │ │ │ ◀────────────── response headers ─────────────────── │ │ anthropic-ratelimit-unified-5h-utilization: 0.30 │ │ anthropic-ratelimit-unified-7d-utilization: 0.72 │ │ │ │ │ │ [if token expired] │ │ │ │ │ ┌─────────────────────────┐ │ │ │ platform.claude.com │ │ │ └────────────┬────────────┘ │ │ │ │ │ POST /v1/oauth/token │ │ grant_type=refresh_token │ │ ──────────────▶ │ │ │ ◀────────────── │ {access_token, refresh_token} │ │ │ │
api.anthropic.com, but token refresh goes to platform.claude.com. This is a common pattern (separate auth server from resource server) but easy to miss.
First, we checked if there was an API key set:
$ if [ -n "$ANTHROPIC_API_KEY" ]; then echo "SET"; else echo "NOT SET"; fi
NOT SET
As expected — Claude Pro users authenticate via OAuth, not API keys.
Explored ~/.claude/ for auth-related files:
$ ls -la ~/.claude/
drwxr-xr-x 23 your-user staff 736 Mar 28 21:50 .
...
-rw------- 1 your-user staff 2361 Mar 22 18:48 stats-cache.json
-rw-r--r-- 1 your-user staff 633 Mar 17 14:40 statusline-command.sh
...
# Also tried globbing for auth/token/oauth files
$ ls ~/.claude/**/*auth* ~/.claude/**/*token* ~/.claude/**/*oauth*
No files found
No credential files on disk — the tokens must be stored more securely.
macOS apps commonly store secrets in the system Keychain:
$ security dump-keychain 2>/dev/null | grep -i -A3 "claude"
0x00000007 <blob>="Claude Code-credentials"
0x00000008 <blob>=<NULL>
"acct"<blob>="$(whoami)"
"svce"<blob>="Claude Code-credentials"
# Extract and examine the structure (values truncated for security)
$ security find-generic-password -s "Claude Code-credentials" -a "$(whoami)" -w \
| python3 -c "import sys,json; ..."
claudeAiOauth:
accessToken: sk-ant-oat01-xcnB-Zh... (len=108)
refreshToken: sk-ant-ort01-_vV1PXO... (len=108)
expiresAt: 1774772239299
subscriptionType: pro
rateLimitTier: default_claude_ai
"Claude Code-credentials" with the username as the account. The JSON contains: accessToken, refreshToken, expiresAt, subscriptionType, and rateLimitTier.
The obvious first attempt — use the token with the standard Messages API:
$ curl -s -i https://api.anthropic.com/v1/messages \
-H "Authorization: Bearer $TOKEN" \
-H "anthropic-version: 2023-06-01" \
-H "content-type: application/json" \
-d '{"model":"claude-haiku-4-5-20251001","max_tokens":1,
"messages":[{"role":"user","content":"hi"}]}'
HTTP/2 401
...
{"type":"error","error":{"type":"authentication_error",
"message":"OAuth authentication is currently not supported."}}
Lesson: The standard API endpoint doesn't accept OAuth tokens by default. There must be a special header or a different endpoint.
Maybe Claude Code uses a different host entirely?
$ for host in "api.claude.ai" "claude.ai/api" "app.claude.ai/api"; do
echo "=== Trying $host ==="
curl -s -o /dev/null -w "HTTP %{http_code}\n" "https://$host/v1/messages" ...
done
=== Trying api.claude.ai ===
HTTP 000 (connection failed — host doesn't exist)
=== Trying claude.ai/api ===
HTTP 403
=== Trying app.claude.ai/api ===
HTTP 000 (connection failed)
None of these alternative hosts worked. The endpoint must be api.anthropic.com after all — but with something extra.
Since Claude Code is a compiled binary, we used strings to extract readable text and find API-related patterns:
# First, locate the binary
$ readlink -f $(which claude)
~/.local/share/claude/versions/2.1.87
$ file ~/.local/share/claude/versions/2.1.87
Mach-O 64-bit executable arm64
# Extract beta header strings
$ strings ~/.local/share/claude/versions/2.1.87 \
| grep "anthropic-beta\|CH9\|bV9"
...
CH9="files-api-2025-04-14,oauth-2025-04-20",bH9="2023-06-01"
...
The variable CH9 is the beta features string and bH9 is the API version. The key is the oauth-2025-04-20 beta flag!
https://api.anthropic.com/v1/messages, but Claude Code sends an extra header:
anthropic-beta: oauth-2025-04-20. This beta flag enables OAuth Bearer token authentication on the Messages API.
$ curl -s -i https://api.anthropic.com/v1/messages \
-H "Authorization: Bearer $TOKEN" \
-H "anthropic-version: 2023-06-01" \
-H "anthropic-beta: files-api-2025-04-14,oauth-2025-04-20" \
-H "content-type: application/json" \
-d '{"model":"claude-haiku-4-5-20251001","max_tokens":1,
"messages":[{"role":"user","content":"hi"}]}'
HTTP/2 200
date: Sun, 29 Mar 2026 05:09:41 GMT
content-type: application/json
anthropic-ratelimit-unified-status: allowed
anthropic-ratelimit-unified-5h-status: allowed
anthropic-ratelimit-unified-5h-reset: 1774774800
anthropic-ratelimit-unified-5h-utilization: 0.25
anthropic-ratelimit-unified-7d-status: allowed
anthropic-ratelimit-unified-7d-reset: 1774983600
anthropic-ratelimit-unified-7d-utilization: 0.71
anthropic-ratelimit-unified-representative-claim: five_hour
anthropic-ratelimit-unified-fallback-percentage: 0.5
anthropic-ratelimit-unified-reset: 1774774800
anthropic-ratelimit-unified-overage-disabled-reason: org_level_disabled_until
anthropic-ratelimit-unified-overage-status: rejected
{"model":"claude-haiku-4-5-20251001","id":"msg_01Rwc4a96Tewfp6VBAjzS6rz",
"type":"message","role":"assistant",
"content":[{"type":"text","text":"#"}],
"stop_reason":"max_tokens",
"usage":{"input_tokens":8,"output_tokens":1,
"service_tier":"standard"}}
The response headers contain the unified rate limit data — exactly what Claude Code displays in the status line.
The anthropic-ratelimit-unified-* headers are specific to Claude Pro/Max subscriptions. They're completely different from the per-minute anthropic-ratelimit-requests-* headers that API key users see.
| Header | Type | Description |
|---|---|---|
unified-status | string | allowed or limited — overall status |
unified-5h-status | string | Status of the 5-hour rolling window |
unified-5h-utilization | float | 0.0–1.0 — fraction of 5h budget consumed |
unified-5h-reset | epoch | Unix timestamp when 5h window resets |
unified-7d-status | string | Status of the 7-day rolling window |
unified-7d-utilization | float | 0.0–1.0 — fraction of 7d budget consumed |
unified-7d-reset | epoch | Unix timestamp when 7d window resets |
unified-representative-claim | string | Which window is the current bottleneck (five_hour or seven_day) |
unified-fallback-percentage | float | Capacity available when one window is exhausted |
unified-overage-status | string | rejected (Pro) or allowed (Max can go over) |
unified-overage-disabled-reason | string | Why overage is disabled |
OAuth access tokens expire. We needed to find how Claude Code refreshes them. This required deeper binary analysis.
# Find refresh-related identifiers
$ strings /path/to/claude | grep -oE '[a-zA-Z_]*[Rr]efresh[a-zA-Z_]*' \
| sort | uniq -c | sort -rn | head -10
386 refresh
164 refreshToken
156 refresh_token
48 refreshed
48 forceRefresh
33 refreshAccessTokenAsync
...
Hundreds of refresh references — many from bundled Azure/AWS SDKs. We need to narrow down to Anthropic-specific code.
$ strings /path/to/claude \
| grep -oE '\{[^}]*refresh_token[^}]*\}' \
| grep -v azure -i
{let q={grant_type:"refresh_token",refresh_token:H,
client_id:m8().CLIENT_ID,
scope:((_?.length)?_:eK_).join(" ")}
{access_token:O,refresh_token:T=H,expires_in:z}
...
This reveals the exact request shape: grant_type, refresh_token, client_id, and scope.
$ strings /path/to/claude | grep "eK_\|VW8" | head -5
eK_=[A1H,_S,
"user:sessions:claude_code",
"user:mcp_servers",
"user:file_upload"],
VW8={
BASE_API_URL:"https://api.anthropic.com",
TOKEN_URL:"https://platform.claude.com/v1/oauth/token",
CLIENT_ID:"9d1c250a-e61b-44d9-88ed-5944d1962f5e",
CLAUDE_AI_AUTHORIZE_URL:"https://claude.com/cai/oauth/authorize",
...
}
| URL | https://platform.claude.com/v1/oauth/token |
| Method | POST |
| Content-Type | application/x-www-form-urlencoded |
| client_id | 9d1c250a-e61b-44d9-88ed-5944d1962f5e |
| Scopes | user:inference user:inference:claude_code user:sessions:claude_code user:mcp_servers user:file_upload |
$ curl -s -i https://platform.claude.com/v1/oauth/token \
-H "content-type: application/x-www-form-urlencoded" \
-d "grant_type=refresh_token\
&refresh_token=$REFRESH_TOKEN\
&client_id=9d1c250a-e61b-44d9-88ed-5944d1962f5e\
&scope=user:inference%20user:inference:claude_code%20..."
HTTP/2 429
{
"error": {
"type": "rate_limit_error",
"message": "Rate limited. Please try again later."
}
}
A 429 (not 400 or 404) confirms the endpoint, format, and credentials are all correct. It's rate-limited because the token was recently refreshed by Claude Code itself.
/v1/oauth/token$ curl -s https://api.anthropic.com/v1/oauth/token \
-H "content-type: application/json" \
-d '{"grant_type":"refresh_token","refresh_token":"..."}'
{"error":{"type":"invalid_request_error","message":"Invalid request format"}}
Why it failed: Wrong Content-Type (JSON vs form-encoded) and wrong host (api.anthropic.com vs platform.claude.com).
api.anthropic.com$ curl -s https://api.anthropic.com/v1/oauth/token \
-H "content-type: application/x-www-form-urlencoded" \
-d "grant_type=refresh_token&refresh_token=..."
{"error":{"type":"invalid_request_error","message":"Invalid request format"}}
Why it failed: Right format, but wrong host. The token endpoint is on platform.claude.com, not api.anthropic.com.
client_id$ curl -s https://platform.claude.com/v1/oauth/token \
-d "grant_type=refresh_token&refresh_token=..."
{"error":{"type":"invalid_request_error","message":"Invalid request format"}}
Why it failed: Missing the required client_id and scope parameters.
Script Keychain platform.claude.com api.anthropic.com │ │ │ │ │ ── read credentials ──▶ │ │ │ │ ◀── JSON blob ───────── │ │ │ │ │ │ │ │ Check: is expiresAt < now? │ │ │ │ │ │ │ [if expired] │ │ │ │ ─── POST /v1/oauth/token ──────────────────────▶ │ │ │ grant_type=refresh_token │ │ │ refresh_token=sk-ant-ort01-... │ │ │ client_id=9d1c250a-... │ │ │ scope=user:inference ... │ │ │ │ │ │ │ ◀── {access_token, refresh_token, expires_in} ── │ │ │ │ │ │ │ ── update credentials ─▶ │ │ │ │ │ │ │ │ [proceed with new token] │ │ │ │ ────── POST /v1/messages ──────────────────────────────────────────────────▶ │ │ Authorization: Bearer {new_access_token} │ │ anthropic-beta: oauth-2025-04-20 │ │ │ │ │ │ ◀──── 200 + rate limit headers ──────────────────────────────────────────── │ │ │ │ │
Putting it all together — a self-contained script that reads credentials, refreshes if expired, calls the API, and displays a usage dashboard:
claude_usage.sh — Complete Source#!/bin/bash
# claude_usage.sh — Check Claude Pro/Max rate limit usage
# Reads OAuth token from macOS Keychain (set by Claude Code)
# Automatically refreshes expired tokens
set -euo pipefail
CLIENT_ID="9d1c250a-e61b-44d9-88ed-5944d1962f5e"
TOKEN_URL="https://platform.claude.com/v1/oauth/token"
SCOPES="user:inference user:inference:claude_code user:sessions:claude_code user:mcp_servers user:file_upload"
# ── Read credentials from Keychain ──────────────────────────────
CREDS_JSON=$(security find-generic-password -s "Claude Code-credentials" -a "$(whoami)" -w)
TOKEN=$(echo "$CREDS_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin)['claudeAiOauth']['accessToken'])")
REFRESH_TOKEN=$(echo "$CREDS_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin)['claudeAiOauth']['refreshToken'])")
EXPIRES_AT=$(echo "$CREDS_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin)['claudeAiOauth']['expiresAt'])")
# ── Refresh token if expired ────────────────────────────────────
NOW_MS=$(python3 -c "import time; print(int(time.time()*1000))")
if [ "$NOW_MS" -ge "$EXPIRES_AT" ]; then
echo "Token expired. Refreshing..."
ENCODED_SCOPES=$(python3 -c "import urllib.parse; print(urllib.parse.quote('$SCOPES'))")
REFRESH_RESP=$(curl -s "$TOKEN_URL" \
-H "content-type: application/x-www-form-urlencoded" \
-d "grant_type=refresh_token&refresh_token=\$REFRESH_TOKEN&client_id=\$CLIENT_ID&scope=\$ENCODED_SCOPES")
if echo "$REFRESH_RESP" | python3 -c "import sys,json; d=json.load(sys.stdin); exit(0 if 'access_token' in d else 1)" 2>/dev/null; then
NEW_ACCESS=$(echo "$REFRESH_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])")
NEW_REFRESH=$(echo "$REFRESH_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('refresh_token',''))")
EXPIRES_IN=$(echo "$REFRESH_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('expires_in',3600))")
NEW_EXPIRES_AT=$(python3 -c "import time; print(int(time.time()*1000 + $EXPIRES_IN*1000))")
# Update the keychain with new tokens
UPDATED_JSON=$(echo "$CREDS_JSON" | python3 -c "
import sys, json
d = json.load(sys.stdin)
d['claudeAiOauth']['accessToken'] = '$NEW_ACCESS'
if '$NEW_REFRESH': d['claudeAiOauth']['refreshToken'] = '$NEW_REFRESH'
d['claudeAiOauth']['expiresAt'] = $NEW_EXPIRES_AT
print(json.dumps(d))
")
security delete-generic-password -s "Claude Code-credentials" -a "$(whoami)" >/dev/null 2>&1 || true
security add-generic-password -s "Claude Code-credentials" -a "$(whoami)" -w "$UPDATED_JSON"
TOKEN="$NEW_ACCESS"
echo "Token refreshed successfully."
else
echo "ERROR: Token refresh failed:"
echo "$REFRESH_RESP" | python3 -m json.tool 2>/dev/null || echo "$REFRESH_RESP"
exit 1
fi
fi
# ── Call API and read rate limit headers ────────────────────────
HEADERS=$(curl -s -D - -o /dev/null https://api.anthropic.com/v1/messages \
-H "Authorization: Bearer $TOKEN" \
-H "anthropic-version: 2023-06-01" \
-H "anthropic-beta: oauth-2025-04-20" \
-H "content-type: application/json" \
-d '{"model":"claude-haiku-4-5-20251001","max_tokens":1,
"messages":[{"role":"user","content":"hi"}]}' 2>/dev/null)
# ── Parse and display ───────────────────────────────────────────
STATUS=$(echo "$HEADERS" | grep "unified-status:" | head -1 | awk '{print $2}' | tr -d '\r')
# ... (parse remaining headers and display dashboard)
╔══════════════════════════════════════════╗
║ Claude Pro Usage Dashboard ║
╠══════════════════════════════════════════╣
║ Status: allowed ║
║──────────────────────────────────────────║
║ 5-Hour Window: allowed ║
║ Used: 55% ║
║ Remaining: 45% ║
║ Resets: Sun Mar 29 02:00 AM PDT║
║──────────────────────────────────────────║
║ 7-Day Window: allowed ║
║ Used: 75% ║
║ Remaining: 25% ║
║ Resets: Tue Mar 31 12:00 PM PDT║
║──────────────────────────────────────────║
║ Overage: rejected ║
╚══════════════════════════════════════════╝
OAuth tokens are stored in the macOS Keychain under service "Claude Code-credentials", as a JSON blob containing accessToken, refreshToken, expiresAt, and metadata.
security find-generic-password -s "Claude Code-credentials" -a "$(whoami)" -w
OAuth tokens require the header anthropic-beta: oauth-2025-04-20 to be accepted by api.anthropic.com/v1/messages. Without it, you get a 401.
Pro/Max plans use a unified rate limit system with two rolling windows (5-hour, 7-day), exposed via anthropic-ratelimit-unified-* response headers.
Refresh via POST https://platform.claude.com/v1/oauth/token with form-encoded body: grant_type=refresh_token, client_id=9d1c250a-..., refresh_token=..., scope=....
The strings command on the compiled binary is the most effective way to extract API endpoints, client IDs, headers, and request shapes from Claude Code.
client_id and beta headers are extracted from Claude Code v2.1.87 and may differ in other versions.