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.
npm install own-authAdd the database connection to the backend environment.
DATABASE_URL=postgres://user:password@localhost:5432/myappCreate the Own Auth tables.
npx own-auth migrateAdd the token pepper before creating the Own Auth instance.
OWN_AUTH_TOKEN_PEPPER=replace-with-a-long-random-secretCreate the Own Auth instance used by the backend routes.
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.
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.
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.
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,
});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.
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,
},
});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.
<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.
{
"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.
export const auth = createOwnAuth({
tokenPepper,
baseUrl: "https://app.example.com",
});The backend calls auth.requestMagicLink and returns the same confirmation for every email address.
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.
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) }
}
}
}
}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
}
}
}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)
)
}
}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
APPLE_CLIENT_ID=com.example.myappAdd 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.
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.
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.
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
)
)
}
}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.
npm install jose --save-prodimport { 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.
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
}
}
}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 });