I added my OpenAI key to an Xcode app. Here’s what actually worked.

Quick outline

  • What I built and why
  • Where I put the key (dev vs. real life)
  • The Swift code that worked for me
  • A real test: bedtime story prompt
  • Bugs I hit and quick fixes
  • What I’d do next

The tiny app I built (and the why)

I wanted one simple thing. Tap a button. Send a prompt. Get a reply from OpenAI. So I built a tiny SwiftUI app in Xcode 15 on my iPhone 14 Pro. Think “one screen, one text box.”

My test idea felt cute. A bedtime story for my kid. Short. Sweet. Easy to spot bugs.

You know what? It worked. But not on the first try. Full disclosure: I documented the entire journey in a step-by-step write-up over on ZyWeb if you’d rather skim screenshots.

Where the key goes (and where it shouldn’t)

Let me be plain.

  • For quick testing: I used a local config file in Xcode. I did not commit it to Git.
  • For real apps: I would not ship the OpenAI key inside the app. Anyone can pull it. I’d use my own server. The app talks to my server. My server talks to OpenAI.

Leaking a secret might seem harmless until you remember that even private chats from A-listers have splashed across headlines. For a real-world cautionary tale, the article on celebrity sexting shows just how quickly sensitive content can escape into the wild and underscores why treating your API key like any other intimate message is non-negotiable.

A similar privacy wake-up call comes from the world of classified personals—people often assume a throw-away handle keeps them anonymous, yet details in a post can still reveal more than intended. If you’ve ever wondered how that plays out in practice, especially in smaller markets, take a look at this Port Arthur Backpage guide to see the precautions savvy users put in place and what you can learn about staying discreet online.

I tried three paths:

  1. Fast dev-only: Secrets.xcconfig (don’t ship this)
  • I made a file named Secrets.xcconfig.
  • I added OPENAI_API_KEY=sk-…mykeyhere.
  • In Xcode, I set this file as a build config for Debug.
  • I mapped that into Info.plist so I could read it at runtime.

Note: this still ends up inside the app. Good for a quick run on my phone. Not good for the App Store.

  1. Safer for real users: Ask the user for a key, then save in Keychain (Keychain Services)
  • The app shows a “Paste your API key” screen.
  • I store it in Keychain.
  • I read it for each call.
  1. Best for production: Use a small server
  • I run a tiny server (I used a simple Node service on my side).
  • The app sends prompts to my server.
  • The server adds the secret key and calls OpenAI.
  • Costs and logs stay on my side. No key leaks.

For a head-start on hosting that proxy securely, consider ZyWeb, which lets you spin up and deploy lightweight API endpoints in minutes.

I used #1 to test. I switched to #2 when I gave the app to a friend to try. I’ll move to #3 if I keep this app. That quick hand-off actually happened during the Vibe Coding Hackathon, so I needed something that wouldn’t leak my key on stage.

I initially prototyped the server on a browser-based IDE while comparing options; my notes on how Replit’s rivals stacked up might save you some time.

The Swift code that worked for me

Here’s my SwiftUI view. It has a TextEditor for the prompt, a button, and a result area.

import SwiftUI

struct ContentView: View {
    @State private var prompt: String = "Tell a 3-sentence bedtime story about a corgi named Bean who finds a red ball."
    @State private var result: String = ""
    @State private var isLoading: Bool = false
    @State private var errorText: String?

    private let api = OpenAIClient()

    var body: some View {
        NavigationView {
            VStack(spacing: 16) {
                TextEditor(text: $prompt)
                    .frame(minHeight: 120)
                    .overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.gray.opacity(0.3)))

                Button(action: sendPrompt) {
                    if isLoading {
                        ProgressView()
                    } else {
                        Text("Ask OpenAI")
                            .bold()
                    }
                }
                .buttonStyle(.borderedProminent)
                .disabled(isLoading || prompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)

                if let errorText {
                    Text(errorText)
                        .foregroundColor(.red)
                        .font(.caption)
                }

                ScrollView {
                    Text(result.isEmpty ? "Result shows up here." : result)
                        .frame(maxWidth: .infinity, alignment: .leading)
                }

                Spacer()
            }
            .padding()
            .navigationTitle("Story Buddy")
        }
    }

    private func sendPrompt() {
        errorText = nil
        result = ""
        isLoading = true

        Task {
            do {
                let reply = try await api.chat(prompt: prompt)
                await MainActor.run {
                    result = reply
                    isLoading = false
                }
            } catch {
                await MainActor.run {
                    errorText = "Error: (error.localizedDescription)"
                    isLoading = false
                }
            }
        }
    }
}

Here’s the simple API client. I used the chat completions endpoint and a small model.

“`swift
import Foundation

struct OpenAIClient {
private let session: URLSession = .shared

// For fast dev: read from Info.plist (Debug only!)
// For real use: pass a key from Keychain or your server.
private var apiKey: String {
    // Try Info.plist first
    if let key = Bundle.main.object(forInfoDictionaryKey: "OPENAI_API_KEY") as? String, !key.isEmpty {
        return key
    }
    // Fallback: read from Keychain (if you store it there)
    if let key = KeychainHelper.shared.read(service: "openai", account: "api_key") {
        return key
    }
    return ""
}

func chat(prompt: String) async throws -> String {
    guard !apiKey.isEmpty else {
        throw NSError(domain: "OpenAIClient", code: -1, userInfo: [NSLocalizedDescriptionKey: "Missing API key"])
    }

    let url = URL(string: "https://api.openai.com/v1/chat/completions")!
    var request = URLRequest(url: url)
    request.httpMethod = "POST"
    request.setValue("Bearer (apiKey)", forHTTPHeaderField: "Authorization")
    request.setValue("application/json", forHTTPHeaderField: "Content-Type")

    let body = ChatRequest(
        model: "gpt-4o-mini",
        messages: [
            Message(role: "system", content: "You are a kind, brief assistant."),
            Message(role: "user", content: prompt)
        ],
        temperature: 0.7
    )

    request.httpBody = try JSONEncoder().encode(body)

    let (data, response) = try await session.data(for: request)

    guard let http = response as? HTTPURLResponse else {
        throw NSError(domain: "OpenAIClient", code: -2, userInfo: [NSLocalizedDescriptionKey: "No HTTP response"])
    }

    if http.statusCode == 401 {
        throw NSError(domain: "OpenAIClient", code: 401, userInfo: [NSLocalizedDescriptionKey: "Unauthorized. Check your key."])
    } else if http.statusCode == 429 {
        throw NSError(domain: "OpenAIClient", code: 429, userInfo: [NSLocalizedDescriptionKey: "Rate limited. Try again."])
    } else if !(200...299).contains(http.statusCode) {
        let text = String(data: data, encoding: .utf8) ?? "Unknown error"
        throw NSError(domain: "OpenAIClient", code: http.statusCode, userInfo: [NSLocalizedDescriptionKey: text])
    }

    let decoded = try JSONDecoder().decode(ChatResponse.self, from: data)
    if let first = decoded.choices.first?.message.content, !first.isEmpty {
        return first
    } else {
        return "No reply."
    }
}

}

// MARK: – Models

struct ChatRequest: Codable {
let model: String
let messages: [Message]
let temperature: Double?
}

struct Message: Codable {
let role: String
let content: String
}

struct ChatResponse: Codable {
let choices: [Choice]

struct Choice: Codable {
    let index: Int