Max

A Deep Dive into Request Interception on iOS

Request interception in iOS
Custom URLProtocol classes allow you to intercept and monitor network requests on iOS.

Intercepting network requests is a powerful technique for debugging, monitoring, and even mocking network traffic in iOS apps. Apple’s URLProtocol system provides a flexible way to observe, modify, or block requests at runtime—without changing your app’s networking code. In this article we will explore how to implement a custom URLProtocol to intercept and modify network requests.

How Request Interception Works on iOS

At the core of request interception is a custom subclass of URLProtocol. By registering your class with the protocolClasses property of a URLSessionConfiguration, you can transparently intercept all HTTP and HTTPS requests made by your app. This will allow you to do the following:

  • See all network requests and responses including body and headers
  • Allows to modify the request or response before it is sent or received
  • Provide enhanced status about the request and response


Injecting Your Interceptor with protocolClasses

To enable interception, add your custom protocol class to the session configuration:

let config = URLSessionConfiguration.shared
config.protocolClasses = [NetworkInterceptor.self]
let session = URLSession(configuration: config)

This ensures that every request made with this session will first be handled by NetworkInterceptor. You can use this technique for debugging, analytics, or request mocking. A common mistake here it related to the timing. You have to run this code as early as possible in your application lifecycle. Any reuest occuring before this code has been executed will not be intercepted. Another limitation are requests made with URLSessionConfiguration.default. This is because URLSessionConfiguration.shared is a singleton.

Implementing a Custom URLProtocol

The following is a simplified example of a custom interceptor class:

public class NetworkInterceptor: URLProtocol, URLSessionDelegate {
    // Determines if this protocol should handle the given request
    public override class func canInit(with request: URLRequest) -> Bool

    // Returns a canonical version of the request
    public override class func canonicalRequest(for request: URLRequest) -> URLRequest

    // Starts loading the request (called by the system)
    public override func startLoading()

    // Stops loading the request
    public override func stopLoading()

    // URLSessionDelegate methods for handling authentication, redirects, etc.
    public func urlSession(_ session: URLSession, task: URLSessionTask, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void)
}

canInit(with:)

This method decides whether the protocol should handle a given request. Typically, you check the scheme (http/https), avoid recursion, and can filter requests based on custom logic (e.g., only intercepting certain hosts).

public override class func canInit(with request: URLRequest) -> Bool {
    // Avoid handling requests twice
    if URLProtocol.property(forKey: "Handled", in: request) != nil {
        return false
    }
    // Only handle http/https
    guard let scheme = request.url?.scheme?.lowercased(), ["http", "https"].contains(scheme) else {
        return false
    }
    return true
}

startLoading()

This is where the magic happens. Mark the request as handled, create a new URLSession (with yourself as delegate), and start the real network request. You can also inject mock responses here if needed.

public override func startLoading() {
    // Mark as handled to avoid recursion
    let newRequest = (request as NSURLRequest).mutableCopy() as! NSMutableURLRequest
    URLProtocol.setProperty(true, forKey: "Handled", in: newRequest)

    // Optionally: Check for mock response
    if let mock = MockRegistry.match(request: newRequest as URLRequest) {
        // Return mock response
        self.client?.urlProtocol(self, didReceive: mock.response, cacheStoragePolicy: .notAllowed)
        self.client?.urlProtocol(self, didLoad: mock.data)
        self.client?.urlProtocolDidFinishLoading(self)
        return
    }

    // Otherwise, perform the real request
    let session = URLSession(configuration: .default, delegate: self, delegateQueue: nil)
    self.task = session.dataTask(with: newRequest as URLRequest)
    self.task?.resume()
}

URLProtocol.setProperty can be useful to avoid infinite recursion. You can check against this property in the canInit method and then return early.

stopLoading()

This cancels the network task if it’s still running:

public override func stopLoading() {
    self.task?.cancel()
}

Handling Authentication and Redirects

By implementing URLSessionDelegate methods, you can handle authentication challenges, redirects, and SSL pinning transparently. This ensures intercepted requests behave just like normal ones.

public func urlSession(_ session: URLSession, task: URLSessionTask, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
    // Forward the challenge to the client or handle as needed
    completionHandler(.performDefaultHandling, nil)
}

Summary

Request interception on iOS is built on top of Apple’s URLProtocol system, allowing for powerful debugging, monitoring, and mocking of network traffic in your app. By injecting your custom URLProtocol into your session’s protocolClasses, you can observe and control all network requests with minimal code changes.