Reverse-Engineering Claude Code's OAuth & Usage API

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.

Open the binary analysis deep dive

Table of Contents

  1. What We Were Trying to Do
  2. Architecture Overview
  3. Phase 1 — Finding the Credentials
  4. Phase 2 — Finding the Right API Endpoint
  5. Phase 3 — The Breakthrough: Beta Headers
  6. Phase 4 — Understanding the Rate Limit Headers
  7. Phase 5 — Reverse-Engineering Token Refresh
  8. The Final Script
  9. Key Findings Summary

Companion doc: Binary Analysis Deep Dive

1. What We Were Trying to Do

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:

  1. Discover how Claude Code gets this usage data
  2. Replicate it as a standalone curl command
  3. Handle token refresh so the script works even when the OAuth token expires
Why is this non-trivial? Claude Pro/Max users don't have an API key (ANTHROPIC_API_KEY). They authenticate via OAuth through Claude.ai. The OAuth flow, endpoints, and required headers are not publicly documented.

2. Architecture Overview

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-20THE 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}     │
       │               │                                        │
Two different hosts! API calls go to 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.

3. Phase 1 — Finding the Credentials

Step 1.1: Check for environment variables Dead End

First, we checked if there was an API key set:

Command & Output
$ 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.

Step 1.2: Search the Claude config directory Reconnaissance

Explored ~/.claude/ for auth-related files:

Command & Output
$ 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.

Step 1.3: Check the macOS Keychain Found It!

macOS apps commonly store secrets in the system Keychain:

Command & Output — Key Discovery
$ 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
Key Finding #1 Claude Code stores OAuth credentials as a JSON blob in the macOS Keychain under the service name "Claude Code-credentials" with the username as the account. The JSON contains: accessToken, refreshToken, expiresAt, subscriptionType, and rateLimitTier.

4. Phase 2 — Finding the Right API Endpoint

Step 2.1: Try the public API with Bearer token 401 — OAuth Not Supported

The obvious first attempt — use the token with the standard Messages API:

Command & Output
$ 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.

Step 2.2: Try alternative hostnames All Failed

Maybe Claude Code uses a different host entirely?

Command & Output
$ 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.

Step 2.3: Examine the Claude Code binary Critical Discovery

Since Claude Code is a compiled binary, we used strings to extract readable text and find API-related patterns:

Command & Output — Finding the Beta Header
# 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!

Key Finding #2 The API endpoint is the standard 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.

5. Phase 3 — The Breakthrough

Step 3.1: API call with the beta header 200 OK!

Command & Output — Success!
$ 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.

6. Phase 4 — Understanding the Rate Limit Headers

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.

HeaderTypeDescription
unified-statusstringallowed or limited — overall status
unified-5h-statusstringStatus of the 5-hour rolling window
unified-5h-utilizationfloat0.0–1.0 — fraction of 5h budget consumed
unified-5h-resetepochUnix timestamp when 5h window resets
unified-7d-statusstringStatus of the 7-day rolling window
unified-7d-utilizationfloat0.0–1.0 — fraction of 7d budget consumed
unified-7d-resetepochUnix timestamp when 7d window resets
unified-representative-claimstringWhich window is the current bottleneck (five_hour or seven_day)
unified-fallback-percentagefloatCapacity available when one window is exhausted
unified-overage-statusstringrejected (Pro) or allowed (Max can go over)
unified-overage-disabled-reasonstringWhy overage is disabled

Two Rolling Windows

5-Hour Window 30% used resets in ~3h 7-Day Window 70% used resets Mar 31 Binding constraint: representative-claim = five_hour When exhausted: fallback = 50% capacity (Pro), overage = rejected

7. Phase 5 — Reverse-Engineering Token Refresh

OAuth access tokens expire. We needed to find how Claude Code refreshes them. This required deeper binary analysis.

Step 5.1: Search for refresh patterns in the binary Reconnaissance

Command & Output
# 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.

Step 5.2: Find the exact request format Critical Discovery

Command & Output — The Refresh Request Shape
$ 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.

Step 5.3: Find CLIENT_ID and TOKEN_URL Critical Discovery

Command & Output — The Full OAuth Config
$ 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",
  ...
}
Key Finding #3 — Token Refresh Endpoint
URLhttps://platform.claude.com/v1/oauth/token
MethodPOST
Content-Typeapplication/x-www-form-urlencoded
client_id9d1c250a-e61b-44d9-88ed-5944d1962f5e
Scopesuser:inference user:inference:claude_code user:sessions:claude_code user:mcp_servers user:file_upload

Step 5.4: Test the refresh endpoint Confirmed (429)

Command & Output
$ 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.

Failed Attempts Along the Way

Attempts that didn't work For Learning

Attempt A: JSON body to /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).

Attempt B: Form-encoded to 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.

Attempt C: Correct host but missing 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.

Token Refresh — Complete Transaction Flow

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 ────────────────────────────────────────────  │
  │                          │                          │                          │

8. The Final Script

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)

Sample Output

╔══════════════════════════════════════════╗
║        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              ║
╚══════════════════════════════════════════╝

9. Key Findings Summary

Finding #1 — Credential Storage

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

Finding #2 — The Beta Header

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.

Finding #3 — Unified Rate Limits

Pro/Max plans use a unified rate limit system with two rolling windows (5-hour, 7-day), exposed via anthropic-ratelimit-unified-* response headers.

Finding #4 — Token Refresh

Refresh via POST https://platform.claude.com/v1/oauth/token with form-encoded body: grant_type=refresh_token, client_id=9d1c250a-..., refresh_token=..., scope=....

Finding #5 — Reverse-Engineering Method

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.

Important Caveats