← Back to Blog
Security Claude API Railway

Keeping Your Claude API Key Safe in iOS Apps

You can't put an API key in an iOS app binary. Anyone can extract it. Here's the architecture that keeps your key safe without slowing down your app.

Daniel Ehrlich · May 2026 · 8 min read

The single most important security decision when building an AI-powered app is where your API key lives. Get it wrong and someone will find your key, run up a massive bill, and Anthropic will send you an invoice you didn't expect.

⚠️ Never do this:
let apiKey = "sk-ant-api03-..." in your Swift code.

iOS app binaries are publicly downloadable from the App Store. Tools like IPA extractors can pull strings from any downloaded app in minutes. Your key will be found.

The Architecture: Proxy Pattern

The fix is a thin backend server that sits between your iOS app and the Anthropic API. Your app never knows the API key — it just sends messages to your server, and the server handles the Anthropic call.

iPhone app
  ↓ POST /api/chat (with your app secret in the header)
Your Railway server
  ↓ POST /v1/messages (with ANTHROPIC_API_KEY from env vars)
Anthropic API
  ↓ response
Your Railway server
  ↓ JSON response
iPhone app

Two separate secrets, two separate channels:

  • ANTHROPIC_API_KEY — lives only in Railway environment variables. Never in code, never in your repo.
  • APP_SECRET_KEY — a second secret you create. Lives in Railway AND hardcoded in Constants.swift. Used to verify that only your iOS app can hit your Railway endpoint.
Why the APP_SECRET_KEY? Without it, anyone who finds your Railway URL can hit your /api/chat endpoint and run up your Anthropic bill. The APP_SECRET_KEY is lightweight authentication — not bulletproof, but it stops casual abuse. For production apps with significant traffic, add rate limiting by IP.

Setting Up Railway

1. Create the Railway project

  1. Sign up at railway.app
  2. New Project → Deploy from GitHub repo
  3. Select the repo that has your backend server code
  4. Railway auto-detects Node.js and deploys

2. Set environment variables

In Railway → your project → Variables tab:

ANTHROPIC_API_KEY = sk-ant-api03-YOUR-REAL-KEY
APP_SECRET_KEY = generate-a-long-random-string-here
PORT = 3000

Generate APP_SECRET_KEY with: openssl rand -hex 32

3. Get your Railway URL

Railway gives you a URL like https://your-app.up.railway.app. This is what your iOS app calls.


The Backend Server Code

This is a minimal Node.js/Express proxy. It's intentionally simple — the only job it has is forwarding requests.

// server.js
const express = require('express');
const app = express();
app.use(express.json({ limit: '4mb' }));

// Health check — Railway uses this to verify the server is up
app.get('/health', (req, res) => res.json({ status: 'ok' }));

app.post('/api/chat', async (req, res) => {
  // Auth: verify the app secret header
  const appKey = req.headers['x-app-key'];
  if (!appKey || appKey !== process.env.APP_SECRET_KEY) {
    return res.status(401).json({ error: 'Unauthorized' });
  }

  try {
    const response = await fetch('https://api.anthropic.com/v1/messages', {
      method: 'POST',
      headers: {
        'x-api-key': process.env.ANTHROPIC_API_KEY,
        'anthropic-version': '2023-06-01',
        'content-type': 'application/json'
      },
      body: JSON.stringify(req.body)
    });

    const data = await response.json();
    res.status(response.status).json(data);
  } catch (err) {
    res.status(500).json({ error: 'Proxy error' });
  }
});

app.listen(process.env.PORT || 3000, () => {
  console.log(`Server running on port ${process.env.PORT}`);
});
// package.json — keep it minimal
{
  "name": "api-proxy",
  "scripts": { "start": "node server.js" },
  "dependencies": { "express": "^4.18.0" }
}

The iOS Client Code

// Constants.swift — hardcode these (not secrets, just URLs and a non-secret key)
enum Constants {
  static let backendURL = "https://your-app.up.railway.app/api/chat"
  static let appSecretKey = "the-same-value-from-railway-APP_SECRET_KEY"
}
Wait — the APP_SECRET_KEY is in Constants.swift? Yes. It's still extractable from the binary, but that's OK. It only authorizes access to your Railway endpoint — not Anthropic's API. If someone finds it and hits your Railway URL, your Railway server limits the damage. Add rate limiting if you want stronger protection. The ANTHROPIC_API_KEY is the one that must never leave the server.
// ClaudeService.swift — the networking layer
func sendMessage(messages: [[String: Any]]) async throws -> String {
  var request = URLRequest(url: URL(string: Constants.backendURL)!)
  request.httpMethod = "POST"
  request.setValue("application/json", forHTTPHeaderField: "Content-Type")
  request.setValue(Constants.appSecretKey, forHTTPHeaderField: "x-app-key")
  request.timeoutInterval = 30

  let body: [String: Any] = [
    "model": "claude-haiku-4-5-20251001",
    "max_tokens": 1024,
    "system": systemPrompt,
    "messages": messages
  ]

  request.httpBody = try JSONSerialization.data(withJSONObject: body)
  let (data, response) = try await URLSession.shared.data(for: request)

  guard let httpResponse = response as? HTTPURLResponse,
        httpResponse.statusCode == 200 else {
    throw APIError.badResponse
  }

  let json = try JSONSerialization.jsonObject(with: data) as! [String: Any]
  let content = json["content"] as! [[String: Any]]
  return content[0]["text"] as! String
}

Testing the Setup

Test your Railway endpoint with curl before writing any Swift code:

curl -X POST https://your-app.up.railway.app/api/chat \
  -H "Content-Type: application/json" \
  -H "x-app-key: your-app-secret" \
  -d '{
    "model": "claude-haiku-4-5-20251001",
    "max_tokens": 100,
    "messages": [{"role": "user", "content": "Hello!"}]
  }'

You should get a Claude response back. If you get 401, check the APP_SECRET_KEY matches. If you get 500, check the ANTHROPIC_API_KEY is set correctly in Railway's Variables tab.


Production Checklist

  • ✅ ANTHROPIC_API_KEY only in Railway environment variables
  • ✅ APP_SECRET_KEY in Railway variables AND Constants.swift
  • Secrets.swift (if you have one) is in .gitignore
  • ✅ Server validates APP_SECRET_KEY on every request
  • ✅ Server returns appropriate HTTP status codes (401 for bad key, 500 for proxy errors)
  • ✅ Set a spending limit in Anthropic Console (console.anthropic.com → Limits)
  • ⚠️ Consider adding rate limiting by IP for high-traffic apps
Set a spending limit immediately. Go to console.anthropic.com → Settings → Limits and set a monthly hard limit. Even if someone finds a way to hit your proxy, they can only run up so much. This is your last line of defense.

← Full Build Guide App Store Submission →

Need help with your AI app architecture?

Book a free 30-minute discovery call →