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:

  1. Token authorization — default mode for public API access using an API key (Project/Engagement integration).
  2. Customer token authorization — optional, more secure mode for private API access using a JWT customer token (Project/Engagement integration).
  3. 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>/customers for tracking of customer data
  • POST /track/v2/projects/<projectToken>/customers/events for tracking of event data
  • POST /track/v2/projects/<projectToken>/campaigns/clicks for tracking campaign events
  • POST /data/v2/projects/<projectToken>/consent/categories for fetching consents
  • POST /webxp/s/<projectToken>/inappmessages?v=1 for fetching InApp messages
  • POST /webxp/projects/<projectToken>/appinbox/fetch for fetching of AppInbox data
  • POST /webxp/projects/<projectToken>/appinbox/markasread for marking of AppInbox message as read
  • POST /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/fetch for fetching of AppInbox data
  • POST /webxp/projects/<projectToken>/appinbox/markasread for 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:

  1. If you enable customer token authorization by setting the configuration flag advancedAuthEnabled to true but the SDK can't find a provider implementation, it will log the following message:
    Advanced authorization flag has been enabled without provider
  2. The registered class musty extend NSObject. If it doesn't, you'll see the following log message:
    Class ExponeaAuthProvider does not conform to NSObject
  3. 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 getAuthorizationToken method.

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 identifyCustomer or anonymize methods, 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 tracking
  • POST /track/u/v1/customers/events?stream_id=<streamId> — event tracking
  • POST /webxp/streams/<streamId>/inappmessages — in-app messages
  • POST /webxp/streams/<streamId>/appinbox/fetch — App Inbox fetch
  • POST /webxp/streams/<streamId>/appinbox/markasread — App Inbox mark as read
  • POST /optimization/streams/<streamId>/recommend/user — recommendations
  • POST /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.

❗️

setSdkAuthToken only 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 }
}
⚠️

Important

The notification fires whenever setSdkAuthToken is 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:

  1. Invokes your JWT error handler once (so you can set the token, e.g. from your backend).
  2. 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 (.expired or .invalid), waits approximately 1 second to allow the integrator to provide a new token via setSdkAuthToken, 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

ReasonWhen triggered
.notProvidedRequest requires JWT but no token is set (pre-flight or 403 without token)
.invalidJWT is malformed or signature verification failed (401 from server)
.expiredJWT exp claim is in the past (pre-flight check or 401 with expired token)
.expiredSoonProactive timer fires ~60s before exp, or pre-flight detects imminent expiry
.insufficientReserved 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:) or stopIntegration(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 setSdkAuthToken is 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".


© Bloomreach, Inc. All rights reserved.