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
- Sign up at railway.app
- New Project → Deploy from GitHub repo
- Select the repo that has your backend server code
- 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.
const express = require('express');
const app = express();
app.use(express.json({ limit: '4mb' }));
app.get('/health', (req, res) => res.json({ status: 'ok' }));
app.post('/api/chat', async (req, res) => {
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}`);
});
{
"name": "api-proxy",
"scripts": { "start": "node server.js" },
"dependencies": { "express": "^4.18.0" }
}
The iOS Client Code
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.
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.