iosswifthttphttp-status-code-403urlsession

URLSession GET request returns 403 in iOS app, but works in Postman with the same token


I'm building a chat app in Swift using URLSession. I have an authenticated GET request to fetch the current user's friends list. The request works perfectly in Postman using the same token and URL, but my iOS app consistently returns a 403 with { "detail": "Not Authenticated" }.

What I’ve Tried:

My Code:

ContactListVC.swift:

private func fetchContacts() {
    ContactService.shared.getFriends { [weak self] success, contacts in
        guard let self = self else { return }
        DispatchQueue.main.async {
            if success, let contacts = contacts, !contacts.isEmpty {
                self.contacts = contacts
                self.tableView.reloadData()
            } else {
                self.contacts = []
                self.showEmptyStateIfNeeded()
            }
        }
    }
}

ContactService.swift:

func getFriends(completion: @escaping (Bool, [ChatPartner]?) -> Void) {
    guard let request = contactRequest.getFriends() else {
        completion(false, nil)
        return
    }

    NetworkService.shared.sendRequest(request, parse: { data in
        guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
              let dataObject = json["data"] as? [String: Any],
              let friendsArray = dataObject["friends"] as? [[String: Any]] else {
            return nil
        }
        return friendsArray.compactMap { ChatPartner(json: $0) }
    }) { result in
        switch result {
        case .success(let partners):
            completion(true, partners)
        case .failure:
            completion(false, nil)
        }
    }
}

ContactRequest.swift:

func getFriends() -> URLRequest? {
    guard let baseURL = BASE_URL else { return nil }
    let url = baseURL.appendingPathComponent("friends")
    var request = URLRequest(url: url)
    request.setValue("Bearer \(token ?? "")", forHTTPHeaderField: "Authorization")
    request.httpMethod = "GET"
    return request
}

NetworkService.swift:

func sendRequest<T>(_ request: URLRequest, parse: @escaping (Data) -> T?, completion: @escaping (Result<T, NetworkError>) -> Void) {
    let task = URLSession.shared.dataTask(with: request) { data, response, error in
        guard let httpResponse = response as? HTTPURLResponse, let data = data else {
            completion(.failure(.invalidResponse))
            return
        }

        switch httpResponse.statusCode {
        case 200:
            if let result = parse(data) {
                completion(.success(result))
            } else {
                completion(.failure(.parsingFailed))
            }
        default:
            completion(.failure(.statusCode(httpResponse.statusCode)))
        }
    }
    task.resume()
}

Info.plist:

<key>NSAppTransportSecurity</key>
<dict>
    <key>NSExceptionDomains</key>
    <dict>
        <key>52.23.164.179</key>
        <dict>
            <key>NSExceptionAllowsInsecureHTTPLoads</key>
            <true/>
            <key>NSIncludesSubdomains</key>
            <true/>
        </dict>
    </dict>
    <key>NSAllowsArbitraryLoads</key>
    <true/>
</dict>

Questions:

  1. Why am I getting a 403 response in iOS but not in Postman with the same token?
  2. Is it possible that some missing headers (e.g. User-Agent) cause the API to reject it?

Extra Notes:


Solution

  • By using Instruments.app, I found that the response status code to the request is actually 307 - a redirect. URLSession follows the redirect by sending a new request, without the authorisation header, and so you get a 403 at the end.

    On the other hand, Postman retains the header, as long as the destination has the same host name. There is also a setting to retain the header even if it has a different host name.

    The redirect is from /friends to /friends/, so I imagine changing the path to /friends/ would prevent the redirect.

    Otherwise, you can pass a URLSessionTaskDelegate when you call dataTask(...). The delegate would pass on the Authorization header to the new request. Here is an example:

    actor SomeDelegate: NSObject, URLSessionTaskDelegate {
        nonisolated func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest) async -> URLRequest? {
            var newRequest = request
            let oldRequest = task.currentRequest
            newRequest.setValue(oldRequest?.value(forHTTPHeaderField: "Authorization"), forHTTPHeaderField: "Authorization")
            return newRequest
        }
    }
    
    let task = URLSession.shared.dataTask(with: request) { ... }
    task.delegate = SomeDelegate()
    task.resume()