Back to guides

Add Own Auth to an iOS app


Own Auth runs in your backend. The iOS app sends credentials with URLSession, stores the returned session token in Keychain, and presents it to your API. Password login, session restoration, Universal Links, Sign in with Apple, and sign-out use the same Swift client.

Set up Own Auth in your backend

Install Own Auth in the backend project.

Backend terminal
npm install own-auth

Add the database connection to the backend environment.

.env
DATABASE_URL=postgres://user:password@localhost:5432/myapp

Create the Own Auth tables.

Backend terminal
npx own-auth migrate

Add the token pepper before creating the Own Auth instance.

.env
OWN_AUTH_TOKEN_PEPPER=replace-with-a-long-random-secret

Create the Own Auth instance used by the backend routes.

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

const tokenPepper = process.env.OWN_AUTH_TOKEN_PEPPER!;

export const auth = createOwnAuth({ tokenPepper });

Store the session token in Keychain

Save the opaque session token as a Keychain generic-password item scoped to the app.

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)
        }
    }
}

Create the iOS auth client

This client returns either a completed session or an MFA challenge. It saves the session token only after authentication is complete.

AuthAPI.swift
import Foundation

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

struct MfaChallenge {
    let challengeToken: String
    let methods: [String]
    let expiresAt: String
}

enum AuthenticationResult {
    case session(AuthUser)
    case mfaRequired(MfaChallenge)
}

private struct AuthenticationResponse: Decodable {
    let status: String
    let user: AuthUser?
    let sessionToken: String?
    let challengeToken: String?
    let methods: [String]?
    let expiresAt: 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 -> AuthenticationResult {
        try await createSession(
            path: "auth/signin",
            body: Credentials(email: email, password: password)
        )
    }

    func signUp(
        name: String,
        email: String,
        password: String
    ) async throws -> AuthenticationResult {
        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 -> AuthenticationResult {
        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(AuthenticationResponse.self, from: data)
        if result.status == "mfa_required" {
            guard let challengeToken = result.challengeToken,
                  let methods = result.methods,
                  let expiresAt = result.expiresAt else {
                throw AuthAPIError.invalidResponse
            }
            return .mfaRequired(
                MfaChallenge(
                    challengeToken: challengeToken,
                    methods: methods,
                    expiresAt: expiresAt
                )
            )
        }

        guard result.status == "complete",
              let user = result.user,
              let sessionToken = result.sessionToken else {
            throw AuthAPIError.invalidResponse
        }
        try sessionStore.save(sessionToken)
        return .session(user)
    }
}

Add the backend sign-up and sign-in endpoints

These handlers call auth.signUpEmailPassword and auth.signInEmailPassword, then return the public user with the opaque session token.

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

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

if (result.status === "mfa_required") {
  return Response.json({
    status: result.status,
    challengeToken: result.challengeToken,
    methods: result.methods,
    expiresAt: result.expiresAt,
  });
}

return Response.json({
  status: result.status,
  user: {
    id: result.user.id,
    email: result.user.email,
    name: result.user.name,
  },
  sessionToken: result.sessionToken,
});

Restore the session at launch

On launch, send the Keychain token to a backend endpoint that calls auth.getCurrentSession. A revoked or expired session returns 401 and is removed from Keychain.

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: {
    id: current.user.id,
    email: current.user.email,
    name: current.user.name,
  },
});
AuthAPI.swift
private struct CurrentSessionResponse: Decodable {
    let user: AuthUser
}

extension AuthAPI {
    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
    }
}

Configure Universal Links for magic-link sign-in

Add the Associated Domains capability and register app.example.com.

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

Serve apple-app-site-association from the well-known path and restrict it to the magic-link route.

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

Request and consume the magic link

auth.requestMagicLink builds the verification URL from baseUrl. Add the Universal Link domain to the existing auth config.

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

The backend calls auth.requestMagicLink and returns the same confirmation for every email address.

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 NSUserActivity. Accept only the configured scheme, host, and path before forwarding its token to the backend.

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) }
                }
        }
    }
}
AuthModel.swift
import Foundation
import SwiftUI

@MainActor
final class AuthModel: ObservableObject {
    @Published private(set) var user: AuthUser?
    @Published private(set) var mfaChallenge: MfaChallenge?

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

    func consumeMagicLink(_ url: URL) async {
        guard url.scheme == "https",
              url.host == "app.example.com",
              url.path == "/auth/magic-link/verify",
              let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
              let token = components.queryItems?.first(where: { $0.name == "token" })?.value,
              !token.isEmpty else {
            return
        }

        do {
            apply(try await authAPI.verifyMagicLink(token: token))
        } catch {
            user = nil
        }
    }

    fileprivate func apply(_ result: AuthenticationResult) {
        switch result {
        case .session(let user):
            self.user = user
            mfaChallenge = nil
        case .mfaRequired(let challenge):
            mfaChallenge = challenge
        }
    }
}
AuthAPI.swift
private struct MagicLinkBody: Encodable {
    let token: String
}

extension AuthAPI {
    func verifyMagicLink(
        token: String
    ) async throws -> AuthenticationResult {
        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 });

if (result.status === "mfa_required") {
  return Response.json({
    status: result.status,
    challengeToken: result.challengeToken,
    methods: result.methods,
    expiresAt: result.expiresAt,
  });
}

return Response.json({
  status: result.status,
  user: {
    id: result.user.id,
    email: result.user.email,
    name: result.user.name,
  },
  sessionToken: result.sessionToken,
});

Add Sign in with Apple

Backend environment
APPLE_CLIENT_ID=com.example.myapp

Add the Sign in with Apple capability and request the email scope so auth.signInWithVerifiedExternalIdentity can create a new user. Generate a nonce for each attempt, send its SHA-256 hash to Apple, and post the original nonce with the 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 = [.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)
    }
}

Generate the nonce with Security and hash it with CryptoKit.

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()
    }
}

Send the identity token and raw nonce through the same session-creating client used by password login.

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

extension AuthAPI {
    func signInWithApple(
        identityToken: String,
        nonce: String
    ) async throws -> AuthenticationResult {
        try await createSession(
            path: "auth/apple",
            body: AppleSignInBody(
                identityToken: identityToken,
                nonce: nonce
            )
        )
    }
}
AuthModel.swift
extension AuthModel {
    func signInWithApple(identityToken: String, nonce: String) async {
        guard let result = try? await authAPI.signInWithApple(
            identityToken: identityToken,
            nonce: nonce
        ) else { return }
        apply(result)
    }
}

Install jose in the backend to verify the Apple token against Apple's signing keys, your app audience, and the nonce before calling auth.signInWithVerifiedExternalIdentity.

Backend terminal
npm install jose --save-prod
apple-signin-handler.ts
import { createHash } from "node:crypto";
import { createRemoteJWKSet, jwtVerify } from "jose";

const appleKeys = createRemoteJWKSet(
  new URL("https://appleid.apple.com/auth/keys"),
);
const appleClientId = process.env.APPLE_CLIENT_ID!;
if (
  typeof body.identityToken !== "string" ||
  typeof body.nonce !== "string"
) {
  return new Response("Invalid request", { status: 400 });
}

const verified = await jwtVerify(body.identityToken, appleKeys, {
  issuer: "https://appleid.apple.com",
  audience: appleClientId,
}).catch(() => null);
if (!verified) {
  return new Response("Invalid Apple credential", { status: 401 });
}

const { payload } = verified;
const nonce = createHash("sha256").update(body.nonce).digest("hex");
if (payload.nonce !== nonce || typeof payload.sub !== "string") {
  return new Response("Invalid Apple credential", { status: 401 });
}

const result = await auth.signInWithVerifiedExternalIdentity({
  provider: "apple",
  providerAccountId: payload.sub,
  email: typeof payload.email === "string" ? payload.email : undefined,
  emailVerified:
    payload.email_verified === true || payload.email_verified === "true",
});

if (result.status === "mfa_required") {
  return Response.json({
    status: result.status,
    challengeToken: result.challengeToken,
    methods: result.methods,
    expiresAt: result.expiresAt,
  });
}

return Response.json({
  status: result.status,
  user: {
    id: result.user.id,
    email: result.user.email,
    name: result.user.name,
  },
  sessionToken: result.sessionToken,
});

Sign out

Ask the backend to revoke the session, then remove the Keychain item even when the request fails.

AuthAPI.swift
extension AuthAPI {
    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
        }
    }
}
signout-handler.ts
const header = request.headers.get("Authorization");
if (!header?.startsWith("Bearer ")) {
  return new Response("Unauthorized", { status: 401 });
}

await auth.signOut(header.slice("Bearer ".length));
return new Response(null, { status: 204 });