Drop-in phone OTP authentication (SMS or WhatsApp) plus WhatsApp marketing attribution for iOS apps. Pure Swift, no third-party dependencies, ships with both headless APIs and pre-built SwiftUI / UIKit components.
.package(url: "https://github.com/quickauthin/quickauth-sdk-ios", from: "0.1.0")Then add "QuickAuth" to your target dependencies.
pod 'QuickAuthIn', '~> 0.1.0'Note: the pod is named
QuickAuthInon CocoaPods (the unsuffixedQuickAuthname was already taken by an unrelated library). Your Swift code still usesimport QuickAuth— only the Podfile entry uses the suffixed name.
The SDK never embeds your client_secret. Instead — same pattern Twilio Verify
uses for its mobile SDKs — your backend mints a short-lived (10 minute)
sessionToken by calling QuickAuth server-to-server, and the SDK calls a
closure (onTokenExpiry) you provide to fetch one whenever it needs a fresh
token.
┌────────────┐ /api/quickauth-token ┌──────────────────┐ POST /v1/sdk/session
│ Your app │ ──────────────────────▶ │ Your backend │ ──────────────────────▶ QuickAuth
│ (this SDK) │ ◀────────────────────── │ (client_secret) │ ◀────────────────────── (returns sessionToken, exp 10m)
└────────────┘ sessionToken └──────────────────┘
The SDK:
- caches the token in-memory,
- parses
expfrom the JWT, - calls
onTokenExpiryagain ~30s before expiry (single-flight: concurrent callers share one refresh), - on a
401response, invalidates the token and retries the request once.
import QuickAuth
@main
struct MyApp: App {
init() {
QuickAuth.shared.initialize(onTokenExpiry: {
// Call YOUR backend; do NOT call QuickAuth directly from the app.
let response = try await myAPI.fetch("/api/quickauth-token")
return response.sessionToken
})
}
var body: some Scene { WindowGroup { ContentView() } }
}The SDK defaults the API base to https://api.quickauth.in and (in DEBUG)
warns you if the Associated Domains entitlement needed for domain-bound SMS
autofill is missing.
import SwiftUI
import QuickAuth
struct LoginView: View {
@State private var phone = "+919876543210"
var body: some View {
QuickAuthLoginButton(
phone: phone,
onSuccess: { jwt in /* save jwt */ },
onError: { err in print(err) }
)
}
}UIKit equivalent: QuickAuthLoginButtonView, QuickAuthOTPTextField.
let session = try await QuickAuth.shared.auth.startOTP(phone: "+919876543210", channel: .auto)
let result = try await QuickAuth.shared.auth.verifyOTP(sessionId: session.sessionId, code: "123456")
print(result.jwt)QuickAuth.shared.auth.observeOTP().sink { code in
self.code = code
}QuickAuth.shared.auth.startWhatsAppLogin(
businessNumber: "+919574980048",
returnURL: URL(string: "https://app.example.com/wa-return")
)Handle the return URL in your app:
.onOpenURL { url in
Task { _ = try? await QuickAuth.shared.attribution.captureLaunch(url: url) }
}try await QuickAuth.shared.attribution.captureLaunch(url: launchURL)
try await QuickAuth.shared.attribution.trackConversion(
event: "signup", value: 0, currency: "INR"
)Your backend exposes a thin endpoint that authenticates the logged-in user (however you do that today) and proxies a call to QuickAuth. Below is a Vapor 4 example; the equivalent Express, Rails, FastAPI etc. is trivial because it's a single POST.
// Sources/App/routes.swift
import Vapor
struct QASessionResponse: Content { let sessionToken: String; let expiresIn: Int }
func routes(_ app: Application) throws {
app.post("api", "quickauth-token") { req async throws -> QASessionResponse in
// 1. Authenticate the request from your app (your existing session / JWT auth).
let _ = try req.auth.require(User.self)
// 2. Server-to-server call to QuickAuth.
let upstream = try await req.client.post("https://api.quickauth.in/v1/sdk/session") {
try $0.content.encode(["scope": "sdk"])
$0.headers.add(name: "X-Client-Id", value: Environment.get("QUICKAUTH_CLIENT_ID")!)
$0.headers.add(name: "X-Client-Secret", value: Environment.get("QUICKAUTH_CLIENT_SECRET")!)
}
struct Upstream: Content { let sessionToken: String; let expiresIn: Int }
let body = try upstream.content.decode(Upstream.self)
return QASessionResponse(sessionToken: body.sessionToken, expiresIn: body.expiresIn)
}
}Node/Express equivalent:
app.post('/api/quickauth-token', requireAuth, async (req, res) => {
const r = await fetch('https://api.quickauth.in/v1/sdk/session', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Client-Id': process.env.QUICKAUTH_CLIENT_ID,
'X-Client-Secret': process.env.QUICKAUTH_CLIENT_SECRET,
},
body: JSON.stringify({ scope: 'sdk' }),
});
res.json(await r.json());
});Never put client_secret in your iOS app bundle — anyone can extract it.
If you already fetched a token (e.g. during onboarding) you can hand it to the SDK so the first API call doesn't have to await the network:
QuickAuth.shared.initialize(config: Config(
onTokenExpiry: { try await myAPI.fetch("/api/quickauth-token").sessionToken },
initialToken: cachedToken
))For internal-distribution apps where you can embed client_secret (MDM, kiosk,
field-ops apps that don't ship to the public App Store) the SDK can mint its own
token directly:
QuickAuth.shared.initialize(config: Config(
unsafeDirectClientId: "qa_client_xxx",
unsafeDirectClientSecret: "qa_secret_yyy"
))The SDK will print
[QuickAuth] ⚠️ UNSAFE mode: client_secret embedded; for trusted-enterprise only
on init. Do not use this for any public-distribution app.
The system keyboard shows a "From Messages" suggestion when a code is detected.
For the domain-bound form (@yourdomain.com #123456) you need:
- Associated Domains entitlement in your target's signing capabilities:
webcredentials:yourdomain.com - Host an
apple-app-site-associationfile at:https://yourdomain.com/.well-known/apple-app-site-association
The SDK's OneTimeCode.hasAssociatedDomainsEntitlement() will best-effort
detect this and log a DEBUG warning if absent.
The SDK respects ATT. It never prompts for tracking on its own. The
fingerprint sent for deferred-deep-link match only includes IDFA when
ATTrackingManager.trackingAuthorizationStatus == .authorized. Otherwise IDFA
is omitted entirely.
Attribution and conversion calls require explicit consent:
QuickAuth.shared.consent.set(true) // user opted in
QuickAuth.shared.consent.set(false) // user opted out — calls return .consentRequiredWithout consent, the SDK throws QuickAuthError.consentRequired for any
attribution call. OTP send/verify are not gated by consent because they
are required for authentication.
| Mode | When to use | API |
|---|---|---|
| Component | Standard login screens; want brand polish for free | QuickAuthLoginButton, QuickAuthOtpField, UIKit equivalents |
| Headless | Custom UI; multi-step flows; non-standard layouts | QuickAuth.shared.auth.startOTP(...), verifyOTP(...), observeOTP() |
The SDK calls the QuickAuth backend (api.quickauth.in by default):
| Method | Path | Purpose |
|---|---|---|
| POST | /v1/sdk/session |
Mint a 10-min sessionToken (your backend; or SDK in unsafe mode) |
| POST | /v1/sdk/auth/initiate |
Start an OTP session |
| POST | /v1/sdk/auth/verify |
Verify the code, get JWT |
| POST | /v1/sdk/attribution/launch |
Match deferred deep link |
| POST | /v1/sdk/attribution/conversion |
Track conversion event |
All authenticated requests carry Authorization: Bearer <sessionToken> and an
Idempotency-Key header.
swift testTests use a URLProtocol mock — no live network required. Covers:
- API client (auth header, idempotency, retries, JWT parsing, HTTP errors,
401 → invalidate + retry once) - TokenManager (single-flight refresh, expiry-aware refresh, JWT parsing, unsafe-direct mint)
- Consent gate (attribution & conversion blocked when consent is
false) - Fingerprint determinism + snake_case wire format
- OTP service (start/verify body shapes, JWT parsing, observer publisher)
MIT — see LICENSE.