Swift SDK / Custom Authenticator
For shipped iOS apps, don’t embed your long-lived projectKey in the binary.
Instead, implement the Authenticator protocol so the SDK fetches a
short-lived bearer token from your backend whenever it needs one.
The protocol
public protocol Authenticator: AnyObject, Sendable {
func getAuthHeader() async throws -> String
}
The native runtime calls getAuthHeader() whenever it needs a fresh token for
an outbound request, and it calls it on every request. The SDK does not
cache delegated tokens for you, so your implementation should cache the token
and refetch only when it’s near expiry (see Token caching).
The method may be called from any thread.
Return-value contract
Return the raw bearer token only - do not include the Bearer prefix.
The SDK constructs the full Authorization: Bearer <token> header itself.
// ✅ correct
return "eyJhbGciOi..."
// ❌ wrong - the SDK prepends `Bearer ` itself
return "Bearer eyJhbGciOi..."
This differs from the JavaScript SDK’s IAuthenticator, which returns the full
Bearer ... value. The JS SDK builds the request in userland; the Swift SDK
passes the token through the native layer, which adds the prefix. Don’t copy
the JS convention here.
Token caching
Unlike the project-key initializer (which caches tokens internally) and the
JavaScript SDK (which wraps your authenticator in an automatic cache), the Swift
Authenticator returns only a token string. The runtime has no expiry to cache
against, so caching is your responsibility.
Have your backend return the token’s lifetime alongside it, cache both on-device,
and return the cached token until it’s close to expiry. Refresh ~60 seconds early
(the margin the SDK uses internally) so a token can’t lapse mid-request:
import Foundation
import Moss
final class BackendTokenAuthenticator: Authenticator {
private let tokenURL: URL
private let store = TokenStore()
init(tokenURL: URL) { self.tokenURL = tokenURL }
func getAuthHeader() async throws -> String {
// Returns the cached token if still valid (a local check, no network);
// otherwise fetches once. Concurrent callers coalesce onto one refresh.
// Returns the raw token only; the SDK adds the "Bearer " prefix.
try await store.token { try await self.fetchToken() }
}
private func fetchToken() async throws -> (token: String, expiresIn: TimeInterval) {
var request = URLRequest(url: tokenURL)
// Never replay a token from URLSession's cache.
request.cachePolicy = .reloadIgnoringLocalCacheData
// Authenticate this request with your own user credential, e.g.:
// request.setValue("Bearer \(mySession)", forHTTPHeaderField: "Authorization")
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
throw URLError(.badServerResponse)
}
let body = try JSONDecoder().decode(TokenResponse.self, from: data)
return (body.token, body.expiresIn)
}
private struct TokenResponse: Decodable {
let token: String
let expiresIn: TimeInterval // seconds
}
}
/// Thread-safe cache. Concurrent callers during a refresh coalesce onto a single
/// fetch, so a cold start or expiry boundary triggers one backend round-trip.
actor TokenStore {
private var token: String?
private var expiresAt: Date = .distantPast
private var refresh: Task<(token: String, expiresIn: TimeInterval), Error>?
func token(
orFetch fetch: @Sendable @escaping () async throws -> (token: String, expiresIn: TimeInterval)
) async throws -> String {
if let token, expiresAt > Date() { return token }
if let refresh { return try await refresh.value.token }
let task = Task { try await fetch() }
refresh = task
defer { refresh = nil }
let fetched = try await task.value
token = fetched.token
expiresAt = Date().addingTimeInterval(max(0, fetched.expiresIn - 60)) // refresh 60s early
return fetched.token
}
}
let auth = BackendTokenAuthenticator(
tokenURL: URL(string: "https://api.yourapp.com/moss-token")!
)
let client = try MossClient(projectId: "your-project-id", authenticator: auth)
Your token endpoint should return JSON shaped like
{ "token": "...", "expiresIn": 3600 } (expiresIn in seconds). With caching in
place your backend is hit only on a cold start and once per token lifetime.
Every query in between is served from the in-memory cache.
A complete, runnable example (the iOS app plus a token-vending backend in Node
and Python) is in examples/ios
in the Moss repo.
See MossClient for
the matching initializer.