Ashutosh Billa

URLProtocol in iOS: Intercepting and Modifying URLSession Requests

April 17, 2026 · 7 min read

URLProtocol is one of the most underused APIs in Foundation. It lets you intercept every network request made through URLSession — before it ever touches the network. Most iOS developers only discover it when they need to mock requests in tests, but it has a broader set of uses: logging, header injection, custom caching, and offline stubs.

This post covers what URLProtocol is, how requests flow through it, and how to implement one correctly in Swift.


What is URLProtocol?

URLProtocol is an abstract class in Foundation that lets you intercept URL loading requests before they reach the network.

Every URLSession operates with a list of registered protocol classes. When a request is made, the system iterates through this list and asks each one: "can you handle this request?"

This happens via canInit(with:). The first protocol that returns true takes over the request entirely — from that point on, the system hands control to your implementation.

You never create instances of URLProtocol directly. You register the class, and the system instantiates it per request when needed.


Request Flow

At a high level:

  • App creates a URLRequest
  • URLSession receives it
  • System checks registered URLProtocol classes in order
  • If none handle it, default network loading continues
  • If one handles it, your protocol is responsible for completing the request

Registering a URLProtocol

You register a protocol on a URLSessionConfiguration, which determines which sessions use your interception logic.

There are two ways to register:

1. Global registration

URLProtocol.registerClass(MockURLProtocol.self)

This applies to all URLSession instances in the process unless explicitly overridden.

In practice, global registration is rarely a good idea. Once registered, every request flows through your protocol — including internal requests you trigger inside startLoading(). Without careful guards, this leads to infinite interception loops and hard-to-debug behavior.

Avoid global registration outside of very controlled environments.

2. Per-session registration (preferred)

let config = URLSessionConfiguration.ephemeral
config.protocolClasses = [MockURLProtocol.self]
 
let session = URLSession(configuration: config)

This scopes the protocol to a specific session, giving you:

  • Isolation — especially useful in test suites
  • Predictable behavior with no side effects on other sessions
  • Easy teardown once the session is released

This is the recommended approach in nearly all cases.

Once your protocol opts into handling a request, the system does not continue default loading for you. You are responsible for either forwarding the request or generating a response manually. If you do neither, the request never completes.


Custom URLProtocol

Once you understand where URLProtocol sits in the stack, implementing one is straightforward. The confusion usually comes from the lifecycle model — it's different from typical networking code.

Subclassing

You subclass URLProtocol and override a small set of methods that define the lifecycle:

final class LoggingURLProtocol: URLProtocol {
 
    override class func canInit(with request: URLRequest) -> Bool {
        // Return true to intercept this request
        return true
    }
 
    override class func canonicalRequest(for request: URLRequest) -> URLRequest {
        // Normalize or modify the request if needed
        return request
    }
 
    override func startLoading() {
        // Perform the request or return a custom response
    }
 
    override func stopLoading() {
        // Cancel in-flight work and clean up
    }
}

Think of these as phases rather than random overrides:

Decision phase

  • canInit(with:) — should I intercept this request?
  • canonicalRequest(for:) — normalize or tweak the request before handling

Execution phase

  • startLoading() — actually handle the request
  • stopLoading() — cancel or clean up

canInit(with:) can be called multiple times. If your protocol forwards the request using another URLSession, that new request will come back through your protocol again — creating an infinite loop.

Guard against this by marking requests as already handled:

override class func canInit(with request: URLRequest) -> Bool {
    if URLProtocol.property(forKey: "Handled", in: request) != nil {
        return false
    }
    return true
}

And set the property before forwarding:

let mutableRequest = (request as NSURLRequest).mutableCopy() as! NSMutableURLRequest
URLProtocol.setProperty(true, forKey: "Handled", in: mutableRequest)

Handling the request lifecycle

Once startLoading() is called, you must finish the job — the system will wait indefinitely otherwise.

You have two options:

1. Forward the request to the network

override func startLoading() {
    Task {
        do {
            let (data, response) = try await URLSession.shared.data(for: request)
            client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
            client?.urlProtocol(self, didLoad: data)
        } catch {
            client?.urlProtocol(self, didFailWithError: error)
        }
        client?.urlProtocolDidFinishLoading(self)
    }
}

2. Return a custom response without a network call

override func startLoading() {
    let data = Data("Mock response".utf8)
 
    let response = HTTPURLResponse(
        url: request.url!,
        statusCode: 200,
        httpVersion: nil,
        headerFields: nil
    )!
 
    client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
    client?.urlProtocol(self, didLoad: data)
    client?.urlProtocolDidFinishLoading(self)
}

Option 2 is the foundation for network mocking in iOS tests.


Where NOT to use URLProtocol

URLProtocol is a low-level interception mechanism. It has no awareness of your app's business logic, no access to your dependency graph, and no natural place to manage shared state.

Avoid using it for:

  • Business logic — it has no context about your app's intent
  • Retry orchestration — hard to manage concurrency and request coordination
  • Authentication flows (token refresh, etc.) — looks tempting, quickly becomes fragile

A useful mental boundary: use URLProtocol for interception, not decision-making.


Practical Use Cases

1. Logging network requests

The simplest and safest use case. You observe requests without changing behavior — just return false from canInit so the request continues normally.

final class RequestLogProtocol: URLProtocol {
    private static let logger = Logger(
        subsystem: Bundle.main.bundleIdentifier ?? "App",
        category: "Network"
    )
 
    override class func canInit(with request: URLRequest) -> Bool {
        Self.logger.info("[\(request.httpMethod ?? "GET")] \(request.url?.absoluteString ?? "")")
        return false
    }
 
    override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
    override func startLoading() {}
    override func stopLoading() {}
}

2. Modifying requests

You can inject headers or modify requests before forwarding them. This is a good fit for attaching auth tokens, API keys, or common parameters.

final class AuthInjectionProtocol: URLProtocol {
    override class func canInit(with request: URLRequest) -> Bool {
        return URLProtocol.property(forKey: "Handled", in: request) == nil
    }
 
    override class func canonicalRequest(for request: URLRequest) -> URLRequest {
        var modified = request
        modified.addValue("Bearer \(Token.current)", forHTTPHeaderField: "Authorization")
        return modified
    }
 
    override func startLoading() {
        let mutable = (request as NSURLRequest).mutableCopy() as! NSMutableURLRequest
        URLProtocol.setProperty(true, forKey: "Handled", in: mutable)
 
        Task {
            do {
                let (data, response) = try await URLSession.shared.data(for: mutable as URLRequest)
                client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
                client?.urlProtocol(self, didLoad: data)
            } catch {
                client?.urlProtocol(self, didFailWithError: error)
            }
            client?.urlProtocolDidFinishLoading(self)
        }
    }
 
    override func stopLoading() {}
}

Draw the line at simple header injection. Once you need retries, token refresh, or shared state across requests, URLProtocol is the wrong layer.


Summary

URLProtocol is a focused tool. It intercepts URLSession requests, hands you full control over the response, and then steps aside.

The lifecycle maps directly to four methods:

  • canInit → decide
  • canonicalRequest → prepare
  • startLoading → execute
  • client callbacks → complete

Everything else — logging, mocking, header injection — is just a variation of this pattern. Register per-session, guard against re-entrant interception, and keep business logic out of the protocol layer.

If you found this useful, you might like what I'm building:

Modus DSLR-like manual control app for iPhone