iOS Backend for Swift Apps: Postgres, Auth and StoreKit

An iOS backend for a Swift app usually has to do five things: store data per user, sign people in (often with Apple), hold files, send push notifications through APNs and check StoreKit purchases on the server. StitchDB covers all five with one project and a Swift package, so you write async Swift against a real Postgres database instead of standing up your own API. This guide builds the pieces in order, with code that follows the StitchDB Swift SDK.

#The stack at a glance

ConcernIn SwiftOn the server
Datastitchdb.from("todos") with Codable structsPostgres + Row Level Security
Sign-institchdb.auth.*, ASWebAuthenticationSessionAuth API, JWT per user
SessionKeychain, refreshed for you1-hour access token by default
Filesstitchdb.storage.from(...)Buckets + policies on storage.objects
Pushstitchdb.push.register(.apns(...))APNs with your .p8 key
Purchasesstitchdb.purchases.sync(verification)Checked with Apple

#1. Add the package

In Xcode choose File → Add Package Dependencies… and paste https://github.com/stitchdb/stitchdb-swift.git. Or in Package.swift:

swift
dependencies: [
  .package(url: "https://github.com/stitchdb/stitchdb-swift.git", branch: "main"),
],
targets: [
  .target(name: "MyApp", dependencies: [.product(name: "StitchDB", package: "stitchdb-swift")]),
]

You need Swift 6.1 (Xcode 16.3) or newer. The SDK runs on iOS, macOS, tvOS, watchOS, visionOS and Linux.

#2. One shared client

swift
import StitchDB

let stitchdb = StitchClient(
  stitchURL: URL(string: "https://<project-ref>.p.stitchdb.com")!,
  stitchKey: "<anon-key>"
)

Create it once, for example as a global or in your app's environment. On Apple platforms the session is kept in the keychain, so users stay signed in between launches without extra code.

Ship only the anon key in the app. Strings in an IPA can be extracted; the anon key is designed for that, because Row Level Security decides what it can do. The service_role key bypasses those rules and must stay on a server.

#3. Schema and policy

SQL
create table public.todos (
  id bigint generated always as identity primary key,
  user_id uuid not null default auth.uid() references auth.users on delete cascade,
  task text not null,
  is_complete boolean not null default false,
  created_at timestamptz not null default now()
);

alter table public.todos enable row level security;

create policy "Users manage their own todos" on public.todos
  for all to authenticated
  using (user_id = auth.uid())
  with check (user_id = auth.uid());

#4. Codable models and queries

swift
struct Todo: Codable, Identifiable {
  var id: Int?
  var task: String
  var isComplete: Bool = false
  enum CodingKeys: String, CodingKey { case id, task, isComplete = "is_complete" }
}

// Select with filters, order and paging
let todos: [Todo] = try await stitchdb.from("todos")
  .select("id, task, is_complete")
  .eq("is_complete", value: false)
  .order("created_at", ascending: false)
  .range(from: 0, to: 9)
  .execute().value

// Insert (returning the row), update, delete
let created: Todo = try await stitchdb.from("todos").insert(Todo(task: "Ship it")).select().single().execute().value
try await stitchdb.from("todos").update(["is_complete": true]).eq("id", value: created.id!).execute()
try await stitchdb.from("todos").delete().eq("id", value: 1).execute()

Writing CodingKeys by hand gets old. Generate the structs instead; you get Codable, Hashable, Sendable types inside enum Database, plus an insert struct per table:

Terminal
stitchdb gen types swift --linked -o Sources/App/DatabaseTypes.swift

Run stitchdb types --lang swift --watch -o Sources/App/DatabaseTypes.swift while you change the schema, and a renamed column becomes a compile error instead of a crash in production. See Typed clients.

In a SwiftUI view, call these from .task { }:

swift
struct TodoList: View {
  @State private var todos: [Todo] = []
  var body: some View {
    List(todos) { Text($0.task) }
      .task {
        todos = (try? await stitchdb.from("todos").select().execute().value) ?? []
      }
  }
}

#5. Sign-in

#E-mail and password

swift
try await stitchdb.auth.signUp(email: "[email protected]", password: "a-strong-password")
let session = try await stitchdb.auth.signIn(email: "[email protected]", password: "a-strong-password")
let user = try await stitchdb.auth.user()
swift
try await stitchdb.auth.signInWithOAuth(provider: .github, redirectTo: URL(string: "myapp://login-callback"))
try await stitchdb.auth.signInWithOTP(email: "[email protected]", redirectTo: URL(string: "myapp://login-callback"))

On Apple platforms signInWithOAuth opens an ASWebAuthenticationSession and returns signed in. Magic links come back through your URL scheme: add myapp://login-callback to the project's redirect URLs, register the scheme in your target's URL types, and pass the URL to the SDK:

swift
.onOpenURL { url in
  Task { try? await stitchdb.auth.session(from: url) }
}

Apple is one of the supported OAuth providers, and for native Sign in with Apple the provider settings have a Skip nonce check option aimed at native sign-in libraries. Check the OAuth docs for the provider setup.

#The hosted page

If you do not want to build sign-in screens, open the hosted page:

swift
let session = try await stitchdb.auth.signInWithHostedPage(
  redirectTo: URL(string: "myapp://login-callback")!,
  view: .signUp   // optional: .signIn, .signUp, .magicLink or .forgot
)

It handles sign-in, sign-up, e-mail codes, password reset and two-step codes in seven languages. When the project's built-in sign-up check is on, signUp and signInWithOTP solve it on a background task, so you add no captcha widget.

#Watch the auth state

swift
for await (event, session) in stitchdb.auth.authStateChanges {
  // .initialSession, .signedIn, .signedOut, .tokenRefreshed, .userUpdated, ...
}

Drive your root view from this stream: signed-out users see onboarding, signed-in users see the app.

#6. Realtime

swift
let channel = stitchdb.channel("todos-changes")
let inserts = channel.postgresChange(InsertAction.self, schema: "public", table: "todos")
try await channel.subscribeWithError()
Task { for await insert in inserts { print(insert.record) } }

Realtime uses URLSession WebSockets, which exist on Apple platforms. On Linux, Foundation has no WebSocket client, so a Swift server on Linux can use the database, Auth, Storage and Functions but not Realtime.

#7. Files

swift
let bucket = stitchdb.storage.from("avatars")

try await bucket.upload("user-1/avatar.png", data: imageData, options: FileOptions(contentType: "image/png", upsert: true))
let publicURL = try bucket.getPublicURL(path: "user-1/avatar.png")                      // public buckets
let signedURL = try await bucket.createSignedURL(path: "user-1/avatar.png", expiresIn: 60) // private

Compress photos from PhotosPicker before uploading (JPEG or HEIC at a reasonable size) and serve thumbnails through image resizing URLs.

#8. APNs push

Create an APNs key in your Apple developer account (Certificates, Identifiers & Profiles › Keys), upload the .p8 with its Key ID, Team ID and bundle ID in Studio › Integrations › Push, and turn off Production for builds run from Xcode. Then register the device token:

swift
// AppDelegate: application(_:didRegisterForRemoteNotificationsWithDeviceToken:)
try await stitchdb.push.register(.apns(deviceToken, topics: ["news"]))

// The in-app inbox
let inbox = try await stitchdb.push.inbox(unread: true)
try await stitchdb.push.markAllRead()

Sending needs the service role, so it happens in SQL, a trigger, an Edge Function or on your server. A trigger can notify a user the moment an order ships, with no app server in between. See Push notifications.

#9. StoreKit 2 purchases

Checking purchases on the device alone is easy to bypass. Send each verified transaction to StitchDB, which checks it with Apple and stores it for the user:

swift
// After a StoreKit 2 purchase (or for each update in Transaction.updates)
let result = try await product.purchase()
if case .success(let verification) = result { try await stitchdb.purchases.sync(verification) }

if try await stitchdb.purchases.hasEntitlement("pro") { unlockPro() }

Set the App Store Server Notifications URL (Version 2) to the address Studio shows, and renewals, refunds and billing problems update the user's status without the app being open. Then guard premium rows in SQL with purchases.has_entitlement(auth.uid(), 'pro'). In-app purchases are included from the Pro plan. The in-app purchase validation guide has the full setup.

#10. Errors

swift
do {
  try await stitchdb.from("todos").insert(["task": ""]).execute()
} catch let error as PostgrestError {
  print(error.code ?? "", error.message, error.hint ?? "")
} catch let error as AuthError {
  print(error.errorCode, error.message)
}

A 42501 code means a policy or grant blocked the request. That is usually a missing policy, not a bug in your Swift code.

#When this is not the right fit

  • If your users are mostly in North America or Asia and every millisecond counts, EU-only hosting (Germany and Finland) adds latency.
  • If you want everything inside Apple's ecosystem, CloudKit needs no third-party account and is built into the platform. It is not Postgres, though, and it is harder to reach from Android or the web.
  • The Swift package is installed from GitHub by branch today; pin a commit in Package.resolved for reproducible builds.

#FAQ

#Do I need my own server for an iOS app?

Not for most apps. The app talks to the REST, Auth and Storage APIs with the anon key, policies authorize each request, and Edge Functions cover the few places where you need a secret.

#Where is the session stored on iOS?

In the keychain. Pass options: StitchClientOptions(auth: .init(storage: ...)) to store it somewhere else; that is required on Linux.

#Can my Android and web apps share the same backend?

Yes. One project serves every client. Kotlin, Flutter, JavaScript, Python and C# SDKs talk to the same API, so the same policies apply everywhere.

#How do I test purchases?

Sandbox and TestFlight purchases are stored with environment = 'sandbox' and grant access unless you turn that off in Studio.

Ship your next iOS app on Postgres: create a free project, or compare plans on the pricing page.