## TL;DR

Claude Code has three auth paths on a headless server: set `ANTHROPIC_API_KEY` if you have API access, use `claude setup-token` to generate a token on your laptop and export `CLAUDE_CODE_OAUTH_TOKEN` on the server if you're on Pro/Max, or SSH port-forward the OAuth callback if you need the browser flow. Codex CLI requires a similar SSH tunnel trick. If you want to skip all of this, [Grass](/content/?ref=codeongrass.com) pre-configures the VM environment at session start so your API key is already wired in and the headless auth problem never comes up.

## Why authentication breaks on a headless server

Claude Code and Codex both default to a browser-based OAuth flow. On a desktop, that's fine — a browser pops open, you click "Authorize", done. On a remote VPS, there's no browser, and both CLIs error out or hang waiting for one.

As of April 2026, there's an [open bug in the official Claude Code repo](https://github.com/anthropics/claude-code/issues/42965?ref=codeongrass.com) labeled `bug` for exactly this: "OAuth login broken for remote/headless server setups." The issue has real traction. Until it's resolved upstream, you need one of the workarounds below.

The good news: all three paths work today, and two of them are clean enough to use in automation.

## What are the three auth paths for Claude Code on a headless server?

### Path 1: ANTHROPIC_API_KEY (cleanest, API access required)

If you have direct API access (pay-per-token, not a Pro/Max subscription), this is the path you want. Claude Code checks for `ANTHROPIC_API_KEY` before attempting OAuth, so setting it skips the browser flow entirely.

```bash
export ANTHROPIC_API_KEY="sk-ant-..."
claude --version   # should print the version without prompting for auth
```

To make it permanent across sessions:

```bash
echo 'export ANTHROPIC_API_KEY="sk-ant-..."' >> ~/.bashrc
source ~/.bashrc
```

Or if you're using a secrets manager or cloud provider env injection, set it there and let it flow in at startup.

**Limitation:** This only works if your account has API billing set up. Claude Pro and Max subscribers don't have direct API access by default — their entitlement is through the Claude.ai interface, not the API. If you try to use a session token from claude.ai as an API key, you'll get a 401.

### Path 2: claude setup-token + CLAUDE_CODE_OAUTH_TOKEN (Pro/Max subscribers)

This is the recommended path for Pro/Max users. The idea: run the browser OAuth flow on your laptop (where you have a browser), capture a portable token, then export it on the server.

**Step 1: On your laptop**, run:

```bash
claude setup-token
```

This opens your browser, completes the OAuth flow, and prints a token to stdout. It looks like:

```
Your Claude Code OAuth token:
claude_oauth_...

Export it on your server:
export CLAUDE_CODE_OAUTH_TOKEN="claude_oauth_..."
```

Copy that token value.

**Step 2: On the server**, set it as an environment variable:

```bash
export CLAUDE_CODE_OAUTH_TOKEN="claude_oauth_..."
```

Persist it:

```bash
echo 'export CLAUDE_CODE_OAUTH_TOKEN="claude_oauth_..."' >> ~/.bashrc
source ~/.bashrc
```

**Step 3: Verify**:

```bash
claude --version
claude "say hello"
```

Claude Code will find the OAuth token and skip the browser login.

**Token expiry note:** These tokens are long-lived but not infinite. If Claude Code starts prompting for re-auth weeks later, re-run `claude setup-token` on your laptop and update the variable on the server.

### Path 3: SSH port forwarding for the OAuth callback (when you need the full browser flow on the server)

Sometimes you can't run `claude setup-token` on a laptop — maybe you're provisioning the server in CI, or you need the auth to happen from the server's identity. In that case, you can SSH port-forward the OAuth callback from the server to your local browser.

Claude Code's OAuth callback runs on `localhost:54545` by default. Forward it:

```bash
# On your local machine, open an SSH tunnel in the background
ssh -N -R 54545:localhost:54545 user@your-server-ip &

# SSH into the server
ssh user@your-server-ip

# On the server, start the auth flow
claude login
```

When `claude login` prints a URL like `http://localhost:54545/oauth/callback?...`, open it in your local browser. The tunnel forwards the browser's response back to the server process.

**If port 54545 is occupied or the callback URL is different**, check which port Claude Code is using by looking at the printed URL in the output, and adjust the `-R` flag accordingly.

This approach is finicky. If the SSH tunnel drops mid-flow, the auth fails silently. Use Path 1 or Path 2 when you can.

## How do you authenticate Codex CLI on a headless VPS?

Codex CLI (OpenAI's agent CLI) has the same problem: its `codex login` command tries to open a browser. The community-discovered workaround is the same SSH tunnel pattern, but the callback port differs.

**Step 1:** Find the port Codex CLI uses for its OAuth callback. Start `codex login` on the server first (without a tunnel) and read the URL it prints before timing out:

```bash
codex login
# Output:
# Opening browser to: http://localhost:XXXXX/auth?...
# Waiting for authentication...
```

Note the port number in the URL.

**Step 2:** Set up the forward from your local machine:

```bash
ssh -N -R XXXXX:localhost:XXXXX user@your-server-ip
```

**Step 3:** Open the URL in your local browser. Codex CLI on the server will pick up the callback.

**Alternatively**, Codex CLI also accepts `OPENAI_API_KEY` as an environment variable for API-key auth, bypassing OAuth entirely:

```bash
export OPENAI_API_KEY="sk-..."
codex "describe this repo"
```

This is the simpler path if you have an OpenAI API account. Like Claude Code's API key path, this skips OAuth completely. For a full walkthrough of persistence, tmux setup, and environment configuration, see [how to do the same for OpenAI Codex CLI on a VPS](/content/blog/codex-cli-vps-setup/index.html).

## How do you keep auth working across reboots and tmux sessions?

A few things break auth after you've got it working once.

**Environment variables not persisting into tmux:** If you set `ANTHROPIC_API_KEY` or `CLAUDE_CODE_OAUTH_TOKEN` in `~/.bashrc`, tmux may not pick it up if it starts as a login shell differently. Fix it by adding the export to `~/.profile` as well:

```bash
echo 'export ANTHROPIC_API_KEY="sk-ant-..."' >> ~/.profile
```

Or pass the variable explicitly when you create a tmux session:

```bash
ANTHROPIC_API_KEY="sk-ant-..." tmux new-session -d -s agent 'claude "run the build"'
```

**Reboots:** Set the variable in `/etc/environment` for system-wide persistence (note: no `export` keyword here):

```bash
# /etc/environment
ANTHROPIC_API_KEY=sk-ant-...
```

**Verifying the variable is set in an existing tmux pane:**

```bash
tmux send-keys -t agent 'echo $ANTHROPIC_API_KEY' Enter
```

If it prints nothing, the variable isn't in that pane's environment.

## How do you avoid the headless auth problem entirely?

The manual auth paths above work, but they're friction — especially `setup-token` rotation, tunnel management, and environment variable propagation across shells and reboots.

[Grass](/content/?ref=codeongrass.com) takes a different approach. When you launch a session, the VM environment is pre-configured with your API key as an environment variable at startup. You bring your own key (BYOK) — Grass never stores or touches it — and the agent runtime is ready to go the moment the session opens. No browser flow, no token rotation, no tunnel setup.

Grass supports Claude Code and OpenCode today. Sessions persist across disconnects (reconnect picks up where you left off), and you can monitor, steer, and approve tool executions from a mobile-first interface. There's a free tier with 10 hours, no credit card needed.

If the auth friction above is the reason you haven't moved your agents to a persistent VM yet, Grass is worth testing.

## FAQ

**Can I use my Claude.ai session cookie as an API key on a headless server?**

No. Session cookies from the Claude.ai web interface are not API keys. The Claude API uses `sk-ant-...` keys issued from console.anthropic.com. Claude Pro/Max subscribers access the agent functionality through OAuth tokens (see Path 2), not through API keys. Using a web session cookie as an `ANTHROPIC_API_KEY` will result in a 401.

**Does `claude setup-token` expire? How often do I need to re-run it?**

OAuth tokens generated by `claude setup-token` are long-lived — typically on the order of weeks to months. The exact TTL depends on Anthropic's session policy, which isn't publicly documented. When your server-side agent starts failing with auth errors, re-run `claude setup-token` on a machine with a browser and update the environment variable on the server.

**Is it safe to store ANTHROPIC_API_KEY in a .bashrc or .profile on a shared server?**

Not really. Anyone with read access to your home directory or who can su to your user can read it. Better options: use a secrets manager (AWS Secrets Manager, HashiCorp Vault, Doppler), inject the key at container/VM launch time as an environment variable from a secrets store, or use a `.env` file with restricted permissions (`chmod 600`). Don't commit it to version control. For a deeper look at these storage patterns and their tradeoffs, see [how to keep your API key secure on the remote server](/content/blog/api-key-security-coding-agents-vps/index.html).

**Why does the SSH port-forward approach sometimes work and sometimes not?**

The OAuth flow is time-limited. If the tunnel takes too long to establish, or the browser takes too long to open the URL, the server-side process times out and the flow fails. Run the tunnel before starting the auth command, and open the browser URL immediately after it appears. Also check that the port isn't already in use on the remote server with `ss -tlnp | grep PORT`.

**Can I run multiple Claude Code agents on the same server with different API keys?**

Yes. The environment variable is per-process, so you can launch each agent in a separate shell or tmux session with its own `ANTHROPIC_API_KEY` set before invoking `claude`. For tmux:

```bash
tmux new-session -d -s agent1 -e ANTHROPIC_API_KEY=sk-ant-key1 'claude "task one"'
tmux new-session -d -s agent2 -e ANTHROPIC_API_KEY=sk-ant-key2 'claude "task two"'
```

Each session runs with its own key, isolated from the other.
