Back to guides

How to use Own Auth with iOS and Swift


Own Auth can power a native iOS app without moving trusted authentication work onto the device. Own Auth runs in your backend, where it can access Postgres and server-only secrets. The iOS app calls narrow HTTPS endpoints and stores only the opaque session token returned after authentication.

This guide uses Swift concurrency, URLSession, Keychain Services, SwiftUI link handling, and AuthenticationServices. It starts with email and password authentication, then adds session restoration, magic links, Sign in with Apple, and sign-out.

How the integration works

Architecture
Native iOS app
-> your HTTPS auth API
-> own-auth on your backend
-> your Postgres database

Auth email
-> Own Auth email provider
-> HTTPS Universal Link or hosted link
-> iOS app
-> your backend verifies the one-time token

Prerequisites

  • An iOS application written in Swift or SwiftUI
  • A Node.js 18 or later backend, or a Cloudflare Worker with Node.js compatibility enabled
  • A Postgres database
  • HTTPS for the API and every authentication link
  • An Apple Developer team, a stable bundle identifier, and an associated web domain

Set up Own Auth in your backend

Install Own Auth and run its database migrations in the backend project. Nothing from this step belongs in the Xcode project.

Backend terminal
npm install own-auth
npx own-auth migrate

Configure the database connection and a long random token pepper in the backend environment. Keep the pepper stable because changing it invalidates sessions and one-time tokens.

.env
DATABASE_URL=postgres://user:password@localhost:5432/myapp
OWN_AUTH_TOKEN_PEPPER=replace-with-a-long-random-secret

Create one shared Own Auth instance. baseUrl is where Own Auth creates application auth links.

auth.ts
import { createOwnAuth } from "own-auth";

export const auth = createOwnAuth({
  tokenPepper: process.env.OWN_AUTH_TOKEN_PEPPER,
  baseUrl: "https://app.example.com",
});

Store the session token in Keychain

Use Keychain Services for the opaque session token. UserDefaults, property lists, and ordinary files are not suitable stores for authentication secrets. The wrapper below saves one generic-password item scoped to the application.

SessionStore.swift
import Foundation
import Security

enum KeychainError: Error {
    case invalidData
    case unexpectedStatus(OSStatus)
}

struct SessionStore {
    private let service = Bundle.main.bundleIdentifier ?? "com.example.myapp"
    private let account = "own_auth_session"

    func save(_ token: String) throws {
        try delete()

        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: account,
            kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
            kSecValueData as String: Data(token.utf8),
        ]
        let status = SecItemAdd(query as CFDictionary, nil)
        guard status == errSecSuccess else {
            throw KeychainError.unexpectedStatus(status)
        }
    }

    func read() throws -> String? {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: account,
            kSecReturnData as String: true,
            kSecMatchLimit as String: kSecMatchLimitOne,
        ]
        var result: CFTypeRef?
        let status = SecItemCopyMatching(query as CFDictionary, &result)

        if status == errSecItemNotFound { return nil }
        guard status == errSecSuccess else {
            throw KeychainError.unexpectedStatus(status)
        }
        guard let data = result as? Data,
              let token = String(data: data, encoding: .utf8) else {
            throw KeychainError.invalidData
        }
        return token
    }

    func delete() throws {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: account,
        ]
        let status = SecItemDelete(query as CFDictionary)
        guard status == errSecSuccess || status == errSecItemNotFound else {
            throw KeychainError.unexpectedStatus(status)
        }
    }
}

The ThisDeviceOnly accessibility setting prevents the token from migrating to another device through a backup. Choose a stricter accessibility class if the app must never access the session while the device is locked.

Create the iOS auth client

Keep URLSession calls and Keychain access behind one client. Decode only the public user fields the application needs, and replace raw backend errors with stable app errors.

AuthAPI.swift
import Foundation

struct AuthUser: Codable {
    let id: String
    let email: String
    let name: String?
}

private struct SessionResponse: Decodable {
    let user: AuthUser
    let sessionToken: String
}

private struct Credentials: Encodable {
    let email: String
    let password: String
}

private struct SignUpDetails: Encodable {
    let name: String
    let email: String
    let password: String
}

enum AuthAPIError: Error {
    case invalidResponse
    case requestFailed
}

actor AuthAPI {
    private let baseURL: URL
    private let urlSession: URLSession
    private let sessionStore: SessionStore
    private let encoder = JSONEncoder()
    private let decoder = JSONDecoder()

    init(
        baseURL: URL,
        urlSession: URLSession = .shared,
        sessionStore: SessionStore = SessionStore()
    ) {
        self.baseURL = baseURL
        self.urlSession = urlSession
        self.sessionStore = sessionStore
    }

    func signIn(email: String, password: String) async throws -> AuthUser {
        try await createSession(
            path: "auth/signin",
            body: Credentials(email: email, password: password)
        )
    }

    func signUp(name: String, email: String, password: String) async throws -> AuthUser {
        try await createSession(
            path: "auth/signup",
            body: SignUpDetails(name: name, email: email, password: password)
        )
    }

    private func createSession<Body: Encodable>(
        path: String,
        body: Body
    ) async throws -> AuthUser {
        var request = URLRequest(url: baseURL.appendingPathComponent(path))
        request.httpMethod = "POST"
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.httpBody = try encoder.encode(body)

        let (data, response) = try await urlSession.data(for: request)
        guard let response = response as? HTTPURLResponse else {
            throw AuthAPIError.invalidResponse
        }
        guard 200..<300 ~= response.statusCode else {
            throw AuthAPIError.requestFailed
        }

        let result = try decoder.decode(SessionResponse.self, from: data)
        try sessionStore.save(result.sessionToken)
        return result.user
    }
}

Add the backend sign-up and sign-in endpoints

Validate request bodies before calling Own Auth. Return the public user and opaque session token over HTTPS. Do not return password hashes, provider credentials, internal account records, or stack traces.

signup-handler.ts
const result = await auth.signUpEmailPassword({
  name: body.name,
  email: body.email,
  password: body.password,
});

return Response.json({
  user: result.user,
  sessionToken: result.sessionToken,
});
signin-handler.ts
const result = await auth.signInEmailPassword({
  email: body.email,
  password: body.password,
});

return Response.json({
  user: result.user,
  sessionToken: result.sessionToken,
});

Return the same safe invalid-credentials response when the email is unknown or the password is wrong. Sign-up can report an existing email because the user needs to resolve that specific conflict.

Restore the session at launch

A token in Keychain may be expired, revoked, or attached to a disabled user. Send it to the backend when the app starts. The backend must call getCurrentSession instead of trusting a user ID sent by the device.

current-session-handler.ts
const header = request.headers.get("Authorization");
const sessionToken = header?.startsWith("Bearer ")
  ? header.slice("Bearer ".length)
  : null;

const current = sessionToken
  ? await auth.getCurrentSession(sessionToken)
  : null;

if (!current) {
  return new Response("Unauthorized", { status: 401 });
}

return Response.json({ user: current.user });
AuthAPI.swift
private struct CurrentSessionResponse: Decodable {
    let user: AuthUser
}

func restoreSession() async throws -> AuthUser? {
    guard let token = try sessionStore.read() else { return nil }

    var request = URLRequest(
        url: baseURL.appendingPathComponent("auth/me")
    )
    request.setValue("Bearer (token)", forHTTPHeaderField: "Authorization")

    let (data, response) = try await urlSession.data(for: request)
    guard let response = response as? HTTPURLResponse else {
        throw AuthAPIError.invalidResponse
    }
    if response.statusCode == 401 {
        try sessionStore.delete()
        return nil
    }
    guard response.statusCode == 200 else {
        throw AuthAPIError.requestFailed
    }

    return try decoder.decode(CurrentSessionResponse.self, from: data).user
}

Keep the launch screen in a loading state while restoration runs. Choose the authenticated or signed-out navigation tree only after the server has answered.

Configure Universal Links for magic-link sign-in

Use an HTTPS Universal Link in authentication email. If the app is installed, iOS can open it directly. If it is not installed, the same URL opens a web fallback. Add the Associated Domains capability to the app target and register the domain without a path or trailing slash.

MyApp.entitlements
<key>com.apple.developer.associated-domains</key>
<array>
    <string>applinks:app.example.com</string>
</array>

Host apple-app-site-association at the well-known HTTPS path with no filename extension and no redirect. Restrict the file to the authentication path instead of allowing every URL on the domain to open the app.

.well-known/apple-app-site-association
{
  "applinks": {
    "details": [
      {
        "appIDs": ["TEAMID.com.example.myapp"],
        "components": [
          { "/": "/auth/magic-link/verify" }
        ]
      }
    ]
  }
}

Request and consume the magic link

The iOS app first asks the backend to send a link. The backend calls requestMagicLink and always returns the same confirmation so callers cannot discover which email addresses exist.

request-magic-link-handler.ts
await auth.requestMagicLink({ email: body.email });

return Response.json({
  message: "If that address can sign in, a link is on its way.",
});

Handle the Universal Link as an NSUserActivity. Validate the scheme, host, path, and token before making a request. The app should never navigate directly to protected data just because a link was opened.

MyApp.swift
import SwiftUI

@main
struct MyApp: App {
    @StateObject private var authModel = AuthModel()

    var body: some Scene {
        WindowGroup {
            RootView()
                .environmentObject(authModel)
                .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
                    guard let url = activity.webpageURL else { return }
                    Task { await authModel.consumeMagicLink(url) }
                }
                .onOpenURL { url in
                    Task { await authModel.consumeMagicLink(url) }
                }
        }
    }
}
AuthModel.swift
@MainActor
final class AuthModel: ObservableObject {
    @Published private(set) var user: AuthUser?

    private let authAPI = AuthAPI(
        baseURL: URL(string: "https://api.example.com")!
    )

    func consumeMagicLink(_ url: URL) async {
        let isUniversalLink = url.scheme == "https" &&
            url.host == "app.example.com" &&
            url.path == "/auth/magic-link/verify"
        let isFallbackLink = url.scheme == "myapp" &&
            url.host == "auth" &&
            url.path == "/magic"

        guard isUniversalLink || isFallbackLink,
              let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
              let token = components.queryItems?.first(where: { $0.name == "token" })?.value,
              !token.isEmpty else {
            return
        }

        do {
            user = try await authAPI.verifyMagicLink(token: token)
        } catch {
            // Present a generic invalid or expired link state.
        }
    }
}
AuthAPI.swift
private struct MagicLinkBody: Encodable {
    let token: String
}

func verifyMagicLink(token: String) async throws -> AuthUser {
    try await createSession(
        path: "auth/magic-link/verify",
        body: MagicLinkBody(token: token)
    )
}
verify-magic-link-handler.ts
const result = await auth.verifyMagicLink({ token: body.token });

return Response.json({
  user: result.user,
  sessionToken: result.sessionToken,
});

Add Sign in with Apple

Add the Sign in with Apple capability to the app target and use AuthenticationServices for the native user interface. Create a cryptographically random nonce for each attempt, put its SHA-256 hash in the Apple request, and send the original nonce with the returned identity token to your backend.

AppleSignInView.swift
import AuthenticationServices
import SwiftUI

struct AppleSignInView: View {
    @EnvironmentObject private var authModel: AuthModel
    @State private var rawNonce = ""

    var body: some View {
        SignInWithAppleButton(.signIn) { request in
            rawNonce = AppleNonce.random()
            request.requestedScopes = [.fullName, .email]
            request.nonce = AppleNonce.sha256(rawNonce)
        } onCompletion: { result in
            guard case let .success(authorization) = result,
                  let credential = authorization.credential as? ASAuthorizationAppleIDCredential,
                  let tokenData = credential.identityToken,
                  let identityToken = String(data: tokenData, encoding: .utf8) else {
                return
            }

            Task {
                await authModel.signInWithApple(
                    identityToken: identityToken,
                    nonce: rawNonce
                )
            }
        }
        .signInWithAppleButtonStyle(.black)
        .frame(height: 50)
    }
}

Build the nonce helper with Security random bytes and CryptoKit SHA256. Never use a predictable value, reuse a nonce, or accept a token whose nonce does not match the current attempt.

AppleNonce.swift
import CryptoKit
import Foundation
import Security

enum AppleNonce {
    private static let characters = Array(
        "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-._"
    )

    static func random(length: Int = 32) -> String {
        precondition(length > 0)
        var result = ""

        while result.count < length {
            var byte: UInt8 = 0
            guard SecRandomCopyBytes(kSecRandomDefault, 1, &byte) == errSecSuccess else {
                fatalError("Unable to create a secure nonce")
            }
            if Int(byte) < characters.count {
                result.append(characters[Int(byte)])
            }
        }
        return result
    }

    static func sha256(_ value: String) -> String {
        SHA256.hash(data: Data(value.utf8))
            .map { String(format: "%02x", $0) }
            .joined()
    }
}

Post the Apple credential through the same session-creating path used by the other sign-in methods. The iOS app treats the identity token as an opaque value and does not make access decisions from its decoded claims.

AuthAPI.swift
private struct AppleSignInBody: Encodable {
    let identityToken: String
    let nonce: String
}

func signInWithApple(
    identityToken: String,
    nonce: String
) async throws -> AuthUser {
    try await createSession(
        path: "auth/apple",
        body: AppleSignInBody(
            identityToken: identityToken,
            nonce: nonce
        )
    )
}
AuthModel.swift
func signInWithApple(identityToken: String, nonce: String) async {
    do {
        user = try await authAPI.signInWithApple(
            identityToken: identityToken,
            nonce: nonce
        )
    } catch {
        // Present a generic sign-in failure.
    }
}

Send the identity token to the backend. The backend verifies Apple's signature, issuer, audience, expiry, and nonce. Only after verification should it pass the trusted Apple subject and optional identity fields to Own Auth.

apple-signin-handler.ts
const appleUser = await verifyAppleIdentityToken({
  identityToken: body.identityToken,
  nonce: body.nonce,
});

const result = await auth.signInWithExternalProvider({
  provider: "apple",
  providerAccountId: appleUser.sub,
  email: appleUser.email,
  emailVerified: appleUser.email_verified === true,
});

return Response.json({
  user: result.user,
  sessionToken: result.sessionToken,
});

Sign out safely

Ask the backend to revoke the database session before deleting the Keychain item. Clear the device copy even when the network request fails so the user is signed out locally.

AuthAPI.swift
func signOut() async throws {
    let token = try sessionStore.read()

    defer { try? sessionStore.delete() }
    guard let token else { return }

    var request = URLRequest(
        url: baseURL.appendingPathComponent("auth/signout")
    )
    request.httpMethod = "POST"
    request.setValue("Bearer (token)", forHTTPHeaderField: "Authorization")

    let (_, response) = try await urlSession.data(for: request)
    guard let response = response as? HTTPURLResponse,
          200..<300 ~= response.statusCode else {
        throw AuthAPIError.requestFailed
    }
}

Production checklist

  • Use HTTPS for the API, Universal Links, and authentication callbacks
  • Keep Own Auth, database credentials, the token pepper, and provider secrets on the backend
  • Store only the opaque session token in Keychain
  • Verify the Own Auth session on every protected backend request
  • Restrict apple-app-site-association to the paths the app genuinely handles
  • Validate the scheme, host, path, and parameters of every incoming URL
  • Verify Apple identity tokens and nonces on the backend before calling Own Auth
  • Never log passwords, session tokens, Apple credentials, magic-link URLs, or one-time tokens
  • Return generic responses for invalid credentials and magic-link requests
  • Test links from Mail and third-party email apps when the app is closed, backgrounded, and open

FAQ

Can Own Auth run inside the iOS app?

No. Own Auth is the server-side authentication core. The iOS app should call backend endpoints and must never connect directly to the Own Auth database.

Should an iOS app use cookies or bearer sessions?

Either can work. An opaque bearer token in Keychain is usually straightforward for a native URLSession client calling a separate API domain. Whichever approach you choose, verify the database-backed Own Auth session on every protected request.

Does a Universal Link authenticate the user by itself?

No. A Universal Link only delivers a URL to the app. The iOS app extracts the expected one-time token and sends it to the backend. Own Auth verifies the token and creates the session there.

Can I trust the claims decoded from an Apple identity token on the device?

No. Treat the device as an untrusted transport. The backend must verify the token's signature and claims against your Apple configuration before passing the identity to signInWithExternalProvider.

Conclusion

A native Own Auth integration keeps the iOS layer focused. Swift owns forms, URLSession, Keychain storage, Universal Links, and Apple system UI. Your backend owns password checks, provider verification, token consumption, session creation, revocation, Postgres access, and every trusted authorization decision.