Authorization for iOS SDK
The SDK exchanges data with the Engagement APIs or the Data hub Event stream APIs through authorized HTTP/HTTPS communication. The SDK supports three authorization modes:
- Token authorization — default mode for public API access using an API key (Project/Engagement integration).
- Customer token authorization — optional, more secure mode for private API access using a JWT customer token (Project/Engagement integration).
- Stream JWT authorization — used exclusively with Data hub / Stream integration, where a backend-issued JWT authenticates all API requests.
Developers should choose the appropriate authorization mode based on their integration type and required level of security.
Token authorization
The default token authorization mode provides public API access using an API key as a token.
Token authorization is used for the following API endpoints by default:
POST /track/v2/projects/<projectToken>/customersfor tracking of customer dataPOST /track/v2/projects/<projectToken>/customers/eventsfor tracking of event dataPOST /track/v2/projects/<projectToken>/campaigns/clicksfor tracking campaign eventsPOST /data/v2/projects/<projectToken>/consent/categoriesfor fetching consentsPOST /webxp/s/<projectToken>/inappmessages?v=1for fetching InApp messagesPOST /webxp/projects/<projectToken>/appinbox/fetchfor fetching of AppInbox dataPOST /webxp/projects/<projectToken>/appinbox/markasreadfor marking of AppInbox message as readPOST /campaigns/send-self-check-notification?project_id=<projectToken>for part of self-check push notification flow
Developers must set the token using the authorization Configuration for iOS SDK parameter when initializing the SDK:
Exponea.shared.configure(
projectToken: "YOUR PROJECT TOKEN",
authorization: .token("YOUR API KEY")
)Customer token authorization
Customer token authorization is optional and provides private API access to select Engagement API endpoints. The customer token contains encoded customer IDs and a signature. When the Bloomreach Engagement API receives a customer token, it first verifies the signature and only processes the request if the signature is valid.
The customer token is encoded using JSON Web Token (JWT), an open industry standard RFC 7519 that defines a compact and self-contained way for securely transmitting information between parties.
The SDK sends the customer token in Bearer <value> format. Currently, the SDK supports customer token authorization for the following Engagement API endpoints:
POST /webxp/projects/<projectToken>/appinbox/fetchfor fetching of AppInbox dataPOST /webxp/projects/<projectToken>/appinbox/markasreadfor marking of AppInbox message as read
Developers can enable customer token authorization by setting the advancedAuthEnabled Configuration for iOS SDK parameter to true when initializing the SDK:
Exponea.shared.configure(
projectToken: "YOUR PROJECT TOKEN",
authorization: .token("YOUR API KEY"),
advancedAuthEnabled: true
)Additionally, developers must implement the AuthorizationProviderType protocol (with @objc attribute), ensuring that the getAuthorizationToken method returns a valid JWT token that encodes the relevant customer ID(s) and private API key ID:
@objc(ExponeaAuthProvider)
public class ExampleAuthProvider: NSObject, AuthorizationProviderType {
required public override init() { }
public func getAuthorizationToken() -> String? {
"YOUR JWT TOKEN"
}
}Customer tokens must be generated by a party that can securely verify the customer's identity. Usually, this means that customer tokens should be generated during the application backend login procedure. When the customer identity is verified (using password, 3rd party authentication, Single Sign-On, etc.), the application backend should generate the customer token and send it to the device running the SDK.
Refer to Generating customer token in the customer token documentation for step-by-step instructions to generate a JWT customer token.
Troubleshooting
If you define ExponeaAuthProvider but it is not working as expected, check the logs for the following:
- If you enable customer token authorization by setting the configuration flag
advancedAuthEnabledtotruebut the SDK can't find a provider implementation, it will log the following message:
Advanced authorization flag has been enabled without provider - The registered class musty extend
NSObject. If it doesn't, you'll see the following log message:
Class ExponeaAuthProvider does not conform to NSObject - The registered class must conform to
AuthorizationProviderType. If it doesn't, you'll see the following log message:
Class ExponeaAuthProvider does not conform to AuthorizationProviderType
Asynchronous implementation of AuthorizationProvider
The customer token value is requested for every HTTP call at runtime. The method getAuthorizationToken() is written for synchronous usage but is invoked in a background thread. Therefore, you are able to block any asynchronous token retrieval (i.e. other HTTP call) and wait for the result by blocking this thread. If the token retrieval fails, you may return a NULL value but the request will automatically fail.
@objc(ExponeaAuthProvider)
public class ExampleAuthProvider: NSObject, AuthorizationProviderType {
required public override init() { }
public func getAuthorizationToken() -> String? {
let semaphore = DispatchSemaphore(value: 0)
var token: String?
let task = yourAuthTokenReqUrl.dataTask(with: request) {
token = $0
semaphore.signal()
}
task.resume()
semaphore.wait()
return token
}
}Different network libraries support different approaches but the principle stays same - feel free to block the invocation of the
getAuthorizationTokenmethod.
Customer token retrieval policy
The customer token value is requested for every HTTP call that requires it.
Typically, JWT tokens have their own expiration lifetime and can be used multiple times. The SDK does not store the token in any cache. Developers may implement their own token cache as they see fit. For example:
@objc(ExponeaAuthProvider)
public class ExampleAuthProvider: NSObject, AuthorizationProviderType {
required public override init() { }
private var tokenCache: String?
private var lifetime: Double?
public func getAuthorizationToken() -> String? {
if tokenCache == nil || hasExpired(lifetime) {
(tokenCache, lifetime) = loadJwtToken()
}
return tokenCache
}
private func loadJwtToken() -> String? {
...
}
}Please consider to store your cached token more securely. iOS offers multiple options such as Keychain or CryptoKit.
A customer token is valid until expiration and is tied to the current customer IDs. If customer IDs change through
identifyCustomeroranonymizemethods, the customer token may become invalid for HTTP requests using the new customer IDs.
Stream JWT authorization (Data Hub)
When the SDK is configured with Stream integration (Data hub), it uses a Stream JWT token for authentication. This is separate from the Engagement customer token and is used for all Stream API requests including tracking, App Inbox, and recommendations.
Stream JWT authorization is used for the following API endpoints:
POST /track/u/v1/customers?stream_id=<streamId>— customer data trackingPOST /track/u/v1/customers/events?stream_id=<streamId>— event trackingPOST /webxp/streams/<streamId>/inappmessages— in-app messagesPOST /webxp/streams/<streamId>/appinbox/fetch— App Inbox fetchPOST /webxp/streams/<streamId>/appinbox/markasread— App Inbox mark as readPOST /optimization/streams/<streamId>/recommend/user— recommendationsPOST /campaigns/send-self-check-notification?project_id=<streamId>— push self-check
Setting the Stream JWT token
Use setSdkAuthToken to provide the JWT token. The token is stored securely in the Keychain and used for all Stream requests:
Exponea.shared.setSdkAuthToken("YOUR_STREAM_JWT_TOKEN")The token is cleared automatically when anonymize(), stopIntegration(), or clearLocalCustomerData(appGroup:) is called. It is also cleared when identifyCustomer(context:properties:timestamp:) is called without a jwtToken in the CustomerIdentity.
setSdkAuthTokenonly has effect when the SDK is configured with Stream integration. In Project/Engagement mode, the token is ignored.
JWT error handling
Register a handler to be notified when the JWT expires, is missing, or becomes invalid. Use this to refresh the token from your backend:
Exponea.shared.setJwtErrorHandler { context in
switch context.reason {
case .expired, .expiredSoon, .notProvided, .invalid, .insufficient:
// Fetch new token from your backend and call setSdkAuthToken
yourBackend.fetchNewJwt { newToken in
Exponea.shared.setSdkAuthToken(newToken)
}
default:
// Forward compatibility: handle future reason values
break
}
}The JwtErrorContext provides reason (why the token needs refreshing) and customerIds (the current customer IDs, so you can request a token for the right customer).
Token refreshed notification
When the JWT token is updated via setSdkAuthToken, the SDK posts JwtAuthManager.tokenRefreshedNotification. You can observe this to refetch data (e.g. App Inbox) after a token refresh:
NotificationCenter.default.addObserver(
forName: JwtAuthManager.tokenRefreshedNotification,
object: nil,
queue: .main
) { _ in
// Refetch App Inbox or other Stream data
Exponea.shared.fetchAppInboxMessages { _ in }
}
ImportantThe notification fires whenever
setSdkAuthTokenis called, even if the provided token is already expired. Observers should not assume the token is valid just because this notification was received. Verify token validity in your logic if needed.
Identify customer with auth context
When using Stream JWT, you can provide customer IDs and the JWT token together via identifyCustomer(context:properties:timestamp:):
let context = CustomerIdentity(
customerIds: ["registered": "[email protected]"],
jwtToken: "YOUR_STREAM_JWT_TOKEN"
)
Exponea.shared.identifyCustomer(context: context, properties: [:], timestamp: nil)The JWT is stored and used for subsequent Stream requests. You can omit jwtToken if the token was already set via setSdkAuthToken.
App Inbox requires JWT in Stream mode
For App Inbox (fetch and mark-as-read) in Stream mode, the JWT is required. If no token is set when you call fetchAppInboxMessages or markAppInboxAsRead, the SDK does not send the request. Instead it:
- Invokes your JWT error handler once (so you can set the token, e.g. from your backend).
- If the token is still missing after that, completes the call with an authorization error (synthetic 401) without hitting the network.
Ensure a valid token is set via setSdkAuthToken (or via identifyCustomer(context:properties:timestamp:) with jwtToken) before using App Inbox in Stream mode.
Token refresh lifecycle
The SDK actively manages the JWT lifecycle through proactive and reactive mechanisms so that integrators can keep the token fresh with minimal disruption.
Proactive refresh timer
When a token is set via setSdkAuthToken, the SDK parses the exp claim and schedules a timer that fires approximately 60 seconds before expiry. When triggered, the JWT error handler is called with reason .expiredSoon, giving the integrator time to fetch a new token before any request fails. This happens without any HTTP failure — the timer fires independently.
Pre-flight token check
Before each Stream HTTP request, the SDK checks the current token and fires the error handler proactively:
- No token set → error handler called with
.notProvided. The request proceeds without an auth header and will receive a 401/403 from the server, triggering the retry mechanism. - Token already expired → error handler called with
.expired. The expired token is still sent with the request; the server will return 401, triggering the retry mechanism. - Token about to expire → error handler called with
.expiredSoon. The request proceeds normally with the current token (which is still valid). The handler fires so the integrator can start a background refresh.
The error handler fires before the HTTP call is made, giving the integrator an early signal. If a new token is provided via setSdkAuthToken before the server response arrives (or during the retry delay), the retry will use the new token.
The error handler is always invoked on the main thread.
Request retry on auth failure
When a Stream request fails with an authentication error, the SDK applies a single-retry mechanism:
- 401 Unauthorized: The SDK invokes the JWT error handler (
.expiredor.invalid), waits approximately 1 second to allow the integrator to provide a new token viasetSdkAuthToken, then retries the request once. If the retry also fails, the request fails permanently. - 403 Forbidden without a token: Same retry-once flow as 401. The error handler is called with
.notProvided. - 403 Forbidden with a valid token: The token is valid but does not have the correct scope for the requested stream. No retry, no token cleared, no error handler invoked. The request fails immediately.
JwtErrorContext.Reason reference
JwtErrorContext.Reason reference| Reason | When triggered |
|---|---|
.notProvided | Request requires JWT but no token is set (pre-flight or 403 without token) |
.invalid | JWT is malformed or signature verification failed (401 from server) |
.expired | JWT exp claim is in the past (pre-flight check or 401 with expired token) |
.expiredSoon | Proactive timer fires ~60s before exp, or pre-flight detects imminent expiry |
.insufficient | Reserved for future use |
JWT claims format
Stream JWTs use HMAC HS512 signing. The expected claims are:
exp(required): Expiration timestamp in Unix seconds. The SDK uses this to schedule the proactive refresh timer and for pre-flight validation.ids(required): A map of customer identity keys to values (e.g.{"registered": "[email protected]"}). These identify the customer the token was issued for.kid(in JWT header): Identifies the signing key used to create the token.
Tokens are issued by the integrator's backend. The SDK does not generate or validate the signature — it only parses exp for lifecycle management.
Token storage and clearing
- The SDK stores the Stream JWT in the Keychain for persistence across app launches.
- When
anonymize()is called in Stream mode, pending events are flushed with the current JWT before the token and identity are cleared. - The token is cleared when
stopIntegration()is called (events are also flushed first). clearLocalCustomerData(appGroup:)also clears the JWT from the Keychain.- You can pass a completion callback to
anonymize(completion:)orstopIntegration(completion:)to be notified when the flush and teardown are complete.
End-to-end integration pattern
The recommended integration order for Stream JWT:
// 1. Configure with StreamSettings
Exponea.shared.configure(
Exponea.StreamSettings(
streamId: "YOUR_STREAM_ID",
baseUrl: "https://api.exponea.com"
),
pushNotificationTracking: .enabled(appGroup: "YOUR_APP_GROUP")
)
// 2. Register JWT error handler (handles both proactive and reactive refresh)
Exponea.shared.setJwtErrorHandler { context in
switch context.reason {
case .expiredSoon:
// Proactive: token is about to expire, refresh in background
yourBackend.fetchNewJwt(for: context.customerIds) { newToken in
Exponea.shared.setSdkAuthToken(newToken)
}
case .expired, .notProvided, .invalid:
// Reactive: token has already expired or is missing
yourBackend.fetchNewJwt(for: context.customerIds) { newToken in
Exponea.shared.setSdkAuthToken(newToken)
}
default:
break
}
}
// 3. Provide initial token
Exponea.shared.setSdkAuthToken("YOUR_INITIAL_JWT_TOKEN")
// 4. Optionally observe token refresh to update UI
NotificationCenter.default.addObserver(
forName: JwtAuthManager.tokenRefreshedNotification,
object: nil,
queue: .main
) { _ in
// Refetch data that depends on a valid token (e.g. App Inbox)
Exponea.shared.fetchAppInboxMessages { _ in }
}
// 5. Identify customer (optional, token can be bundled with identity)
let identity = CustomerIdentity(
customerIds: ["registered": "[email protected]"],
jwtToken: "YOUR_STREAM_JWT_TOKEN"
)
Exponea.shared.identifyCustomer(context: identity, properties: [:], timestamp: nil)When the error handler fires (step 2), the SDK automatically retries the failed request after a ~1-second delay. If
setSdkAuthTokenis called within that window, the retry uses the new token.
Configure application ID
Multiple mobile apps: If your Engagement project supports multiple mobile apps, specify the applicationID in your configuration. This helps distinguish between different apps in your project.
Exponea.shared.configure(
...,
applicationID = "<Your application id>",
...
)Make sure your applicationID value matches exactly Application ID configured in your Bloomreach Engagement under Project Settings > Campaigns > Channels > Push Notifications.
Single mobile app: If your Engagement project supports only one app, you can skip the applicationID configuration. The SDK will automatically use the default value "default-application".
Updated 19 days ago
