Swift / SwiftUI
The BaseIdP Swift package — native PKCE login for SwiftUI apps on iOS, iPadOS, macOS, Mac Catalyst, and visionOS, with a Keychain-backed session and a server-handoff helper.
The Swift SDK is the Apple half of Base IdP. It opens the system browser with
ASWebAuthenticationSession, runs PKCE, exchanges the code for tokens, fetches
the verified principal, and stores the session in the Keychain so a returning
user is already signed in on launch. It is a public client — no secret ever
ships in the app binary.
The whole integration is one value (clientId) and a few lines of SwiftUI. If
you have used the Flutter SDK, this is the same shape in Swift.
Zero dependencies
The package uses only first-party frameworks — AuthenticationServices,
CryptoKit, Security, and SwiftUI. There is nothing else to vendor.
Install
The package lives at github.com/squareexp/base-idp-swift. Add it with Swift Package Manager.
In Xcode
- File → Add Package Dependencies…
- Paste the repo URL:
https://github.com/squareexp/base-idp-swift - Set the dependency rule to Up to Next Major from
1.0.0. - Add the
BaseIdPlibrary product to your app target.
In Package.swift
dependencies: [
.package(url: "https://github.com/squareexp/base-idp-swift.git", from: "1.0.0"),
],
targets: [
.target(
name: "MyApp",
dependencies: [
.product(name: "BaseIdP", package: "base-idp-swift"),
]
),
]Minimum deployment target
BaseIdP requires iOS 15, macOS 12, Mac Catalyst 15, or
visionOS 1. If your app targets something older, Xcode reports
"…was compiled for a newer deployment target." Raise your target or pin an
older SDK release.
Get set up in four steps
Follow these in order. Most "it builds but login never returns" problems come from skipping step 2.
Add your client id
The client id is the one value you type. It is public — it is fine to commit. Put it in your app target's Info.plist:
<key>BASE_IDP_CLIENT_ID</key>
<string>sq_live_yourapp</string>You will read it with BaseIdPConfig.fromInfoPlist(), or pass it directly to
BaseIdPAuth(clientId:).
Never add a client secret
Do not add BASE_IDP_CLIENT_SECRET to an app. A secret in a shipped binary is
a leaked secret. Native apps are public clients and use PKCE. If your client is
registered as confidential, sign-in fails immediately with
confidential_client_in_mobile — see Troubleshooting.
Register your callback URL scheme
Your app needs a custom URL scheme that matches the redirect URI registered for
the client (for example myapp://auth/callback → scheme myapp). Add it under
your target's Info → URL Types, or in Info.plist:
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>com.example.myapp.callback</string>
<key>CFBundleURLSchemes</key>
<array>
<string>myapp</string>
</array>
</dict>
</array>The scheme (myapp, without ://) must match the redirect URI registered in
your Base IdP app exactly. This is the single most common source of the
"browser opens, never comes back" bug.
You usually do not pass a redirect URI in code
The SDK auto-selects the registered custom-scheme callback from your client config. Pass one explicitly only if the client registers more than one and you want a specific match.
Create the auth manager
import BaseIdP
@MainActor
let auth = BaseIdPAuth(clientId: "sq_live_yourapp")
// or, reading Info.plist:
// let auth = try BaseIdPAuth(config: BaseIdPConfig.fromInfoPlist())BaseIdPAuth is an ObservableObject. Hold it with @StateObject at the root
of your app.
Add a sign-in button
import SwiftUI
import BaseIdP
struct ContentView: View {
@StateObject private var auth = BaseIdPAuth(clientId: "sq_live_yourapp")
var body: some View {
switch auth.state {
case .signedIn:
if let user = auth.principal {
Text("Signed in as \(user.name ?? user.email ?? user.id)")
}
Button("Sign out") { auth.signOut() }
case .authenticating:
ProgressView()
case .signedOut, .failed:
SignInWithBaseIdPButton(auth: auth)
if case .failed(let error) = auth.state {
Text(error.message).foregroundStyle(.red)
}
}
}
}That is a complete, working login. BaseIdPAuth restores any saved session from
the Keychain in its initializer, so a returning user renders as .signedIn on
the first frame.
Driving it yourself
If you would rather not use the button, call signIn() directly. It is async
and throws BaseIdPError.
do {
let session = try await auth.signIn()
print(session.principal.email ?? "no email")
} catch let error as BaseIdPError {
print(error.code, error.message)
}signIn():
- Resolves the client config from Base IdP (cached after the first call).
- Rejects confidential clients — a native app must be public.
- Picks the redirect URI and derives the callback scheme.
- Generates a PKCE verifier/challenge and a random
state. - Presents
ASWebAuthenticationSessionand awaits the redirect. - Validates
stateand the redirect URI, exchanges the code, fetches/v1/me. - Persists the session and publishes
state = .signedIn.
The session and the server handoff
If your SwiftUI app talks to your own backend, send the session to your
exchange endpoint after login. serverHandoff() returns the canonical shape.
let session = try await auth.signIn()
var request = URLRequest(url: URL(string: "https://api.example.com/auth/idp-exchange")!)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONSerialization.data(withJSONObject: session.serverHandoff())
let (data, _) = try await URLSession.shared.data(for: request)The payload:
{
"access_token": "v4.public.eyJ...",
"refresh_token": "sqr_rt_v1_...",
"token_type": "PASETO",
"expires_in": 3600,
"principal": { "gid": "...", "sub": "...", "email": "...", "role": "..." }
}Your backend verifies the PASETO signature (offline against the published
Ed25519 key, or by calling /v1/me), upserts a user, and issues its own
session. Any verify-only service works — see Frameworks.
Keeping the access token fresh
validAccessToken() refreshes only when the current token is expired (or within
leeway of expiring), then returns a usable token.
let token = try await auth.validAccessToken()
var request = URLRequest(url: myAPIURL)
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")Token persistence
By default the session is stored in the Keychain
(kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly), scoped to your client id.
Swap the store when you need different behaviour:
// Require sign-in every launch (nothing persisted):
BaseIdPAuth(clientId: "sq_live_yourapp", store: InMemorySessionStore())Pass prefersEphemeralSession: true to force a fresh login with no shared
Safari cookies (no silent SSO) — the right default for account switchers.
Sign out
auth.signOut()This clears the local Keychain session and resets state to .signedOut. It
does not revoke the token on the server; do that from your backend if you need
server-side revocation.
Errors
Every failure is a BaseIdPError with a stable code shared across all Base
IdP SDKs. Switch on code, show message to developers (never to end users).
code | Meaning |
|---|---|
login_cancelled | The user dismissed the auth browser. Not really an error — show nothing. |
confidential_client_in_mobile | The client is registered confidential. Use a public client, or exchange on a backend. |
no_redirect_uri | No custom-scheme callback is registered for the client. |
invalid_redirect_uri | The callback URL did not match the registered redirect URI. |
invalid_state | The callback state did not match the login request (possible tampering). |
config_discovery_failed | POST /v1/client-config rejected the client id. |
token_exchange_failed | POST /oauth2/token rejected the code exchange. |
principal_fetch_failed | GET /v1/me rejected the access token. |
web_auth_start_failed | The system could not start the auth session (usually a missing presentation anchor). |
do {
_ = try await auth.signIn()
} catch let error as BaseIdPError {
switch error.code {
case "login_cancelled":
break // user backed out — no message
default:
showAlert(error.message)
}
}Troubleshooting
The install and first-run problems, in the order people hit them.
No such module 'BaseIdP'
The package resolved but the product is not on your target. Select your app target → General → Frameworks, Libraries, and Embedded Content and confirm BaseIdP is listed. If not, add it. Then File → Packages → Reset Package Caches and rebuild.
Package … was compiled for a newer deployment target
Your app's minimum deployment target is below the SDK's (iOS 15 / macOS 12). Raise it in General → Minimum Deployments, or pin an older SDK release.
The browser opens, but never comes back
This is almost always a scheme mismatch. Check all three agree, exactly:
- The redirect URI registered in your Base IdP app, e.g.
myapp://auth/callback. - The URL Types scheme in your target (
myapp, no://). - What the SDK derives — it uses the scheme from the resolved redirect URI.
If they disagree, iOS has no route back to your app and the session hangs until
the user cancels (which surfaces as login_cancelled).
confidential_client_in_mobile
Your client is registered as confidential (it has a secret). A native app can't keep a secret. Either register a public client for the app, or keep the confidential client and run the code exchange on a backend, forwarding only the authorization code from the device.
config_discovery_failed
The client id is wrong, inactive, or from the wrong environment. Confirm the id
in the console and that you are pointing at the right issuer. For local
development against a non-production Base IdP, set BASE_IDP_ISSUER in
Info.plist (production is the default and needs no override).
web_auth_start_failed or a crash on presentationAnchor
The SDK looks for your app's active foreground window to anchor the browser.
This can fail very early in launch or in unusual scene setups. Trigger sign-in
from a view that is actually on screen (e.g. from a button tap), not from
init or onAppear of a view that has not been presented yet.
Login works in Debug but not in a release/TestFlight build
Confirm the URL Types and bundle id are configured for the release configuration, not only Debug, and that the redirect URI registered in Base IdP matches the production scheme. Entitlements and bundle id must line up in the archived build.
The user is signed in again with the same account every time
ASWebAuthenticationSession shares cookies with Safari by default, which gives
you silent SSO. If you want an account picker instead, create the manager with
prefersEphemeralSession: true.
Keychain errors (errSecMissingEntitlement) on device
If you use App Groups or share the Keychain across an extension, give the store
a matching access group by supplying your own KeychainSessionStore
service. Plain single-app usage needs no entitlement.