How to Authenticate Claude Code and Codex on a Headless VPS
How to SSH into a remote Linux server, install Claude Code, keep it running persistently with tmux, and optionally connect to the session from your phone without a terminal.
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 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 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.
export ANTHROPIC_API_KEY="sk-ant-..."
claude --version # should print the version without prompting for auth
To make it permanent across sessions:
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:
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:
export CLAUDE_CODE_OAUTH_TOKEN="claude_oauth_..."
Persist it:
echo 'export CLAUDE_CODE_OAUTH_TOKEN="claude_oauth_..."' >> ~/.bashrc
source ~/.bashrc
Step 3: Verify:
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:
# 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:
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:
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:
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.
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:
echo 'export ANTHROPIC_API_KEY="sk-ant-..."' >> ~/.profile
Or pass the variable explicitly when you create a tmux session:
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):
# /etc/environment
ANTHROPIC_API_KEY=sk-ant-...
Verifying the variable is set in an existing tmux pane:
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 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.
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:
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.