1<!DOCTYPE html> 2<html> 3 <head> 4 <meta charset="utf-8"> 5 <title>Distributing iOS applications within a company from a website without App Store | Declaration of VAR</title> 6 <link rel="canonical" href="https://decovar.dev/blog/2025/04/03/distributing-ios-applications-without-app-store/"> 7 8 <meta name="generator" content="Hugo 0.164.0"> 9 <meta name="viewport" content="width=device-width, initial-scale=1"><link rel="apple-touch-icon" sizes="120x120" href="/apple-touch-icon.png"> 10<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png"> 11<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png"> 12<link rel="manifest" href="/site.webmanifest"> 13<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5"> 14<meta name="msapplication-TileColor" content="#da532c"> 15<meta name="theme-color" content="#ffffff"> 16<meta name="author" content="retif"> 17 <meta name="description" content="It is possible (always has been) to distribute iOS applications within a company via one's own website (so without App Store). The only two things (aside from Apple (Enterprise?) Developer account) you need is a special manifest.plist file and a URI with itms-services:// scheme."> 18 19 20 <link rel="alternate" type="application/rss+xml" href="/index.xml" title="Declaration of VAR"> 21 22 23 24 25 26 <link rel="stylesheet" href="https://decovar.dev/css/_main.min.549391869168c0284e4dc8f955a6f367ce7760d23485c2782d96f6f295e6ec0f.css"> 27 28
28<script type="application/ld+json"> 29 { 30 "@context": "https://schema.org", 31 "@type": "BlogPosting", 32 "headline": "Distributing iOS applications within a company from a website without App Store", 33 "image": "https://decovar.dev/blog/2025/04/03/distributing-ios-applications-without-app-store/images/installing-ios-application-from-website-blurred.png", 34 "author": 35 { 36 "@type": "Person", 37 "name": "retif", 38 "url": "https://decovar.dev/about/" 39 }, 40 "datePublished": "2025-04-03T13:52:24+02:00", 41 "dateModified": "2025-04-04T10:21:05+02:00", 42 "keywords": "apple, ios, web, dotnet" 43 } 44 </script>
44 45 46 </head> 47 <body><header id="header"> 48 <h1 id="homeTitle"><a href="/">Declaration of VAR</a></h1> 49 <nav id="navigation-desktop" role="navigation"><a href="/blog/">Blog</a> 50<a href="/projects/">Projects</a> 51<a href="https://status.decovar.dev/">Status</a> 52<a href="/etc/">[ etc ]</a> 53<a href="/about/">About</a> 54</nav> 55 <button id="hamburger" class="hamburger hamburger--collapse" type="button" 56 onclick="toggleHamburger(this);"> 57 <span class="hamburger-box"> 58 <span class="hamburger-inner"></span> 59 </span> 60 </button> 61</header> 62<nav id="navigation-mobile" role="navigation"><a href="/blog/">Blog</a> 63<a href="/projects/">Projects</a> 64<a href="https://status.decovar.dev/">Status</a> 65<a href="/etc/">[ etc ]</a> 66<a href="/about/">About</a> 67</nav> 68<main id="main" aria-role="main"> 69 <div id="main-container"> 70 <div style="margin-bottom:20px;"> 71 <article class="post"> 72 <div> 73 <h1 class="post-title">Distributing iOS applications within a company from a website without App Store</h1> 74 <div class="post-meta"> 75 <div title="Published at"> 76 <span><i class="bi-calendar"></i></span> 77 2025-04-03 13:52:24 +0200 78 </div> 79 80 <div title="Last modified at"> 81 <span><i class="bi-pencil"></i></span> 82 2025-04-04 10:21:05 +0200 83 </div> 84 85 <div><i class="bi-clock"></i> 25 min read</div> 86 </div> 87 </div> 88 <div class="post-content"> 89 <p>We’ve been using <a href="https://appcenter.ms" rel="external">App Center</a> (<em>former HockeyApp, which got <a href="https://devblogs.microsoft.com/appcenter/hockeyapp-is-being-retired/" rel="external">retired</a> and replaced by App Center</em>) for distributing our iOS applications within the company. And when we learned about a year ago that App Center also is going to be <a href="https://learn.microsoft.com/en-us/appcenter/retirement" rel="external">retired</a> on 2025-03-31, we started looking at alternatives. Having found none that would be good enough, we decided to implement our own.</p> 90 91 92 <img class="image-post" loading="lazy" src="/blog/2025/04/03/distributing-ios-applications-without-app-store/images/installing-ios-application-from-website-blurred.png" alt="Installing an iOS application from a website"> 93 94<p>Since we already had a .NET/MVC-based website for distribution-related stuff, we added this functionality there, and that turned out to be a rather trivial task. The .NET/MVC has nothing to do with it really - that’s just what we had, and you’ll see for yourself that this can be done with any other framework or even with a bare static website.</p> 95<p>I should have published this article much earlier before the App Center retirement date, and I was intending too, but I just never found time, sorry about that.</p> 96<nav id="TableOfContents"> 97 <ul> 98 <li><a href="#why-would-one-need-app-center-or-a-similar-service">Why would one need App Center or a similar service</a> 99 <ul> 100 <li><a href="#alternatives">Alternatives</a></li> 101 <li><a href="#app-center-itself-wasnt-great-either">App Center itself wasn’t great either</a></li> 102 </ul> 103 </li> 104 <li><a href="#how-is-it-even-possible-to-distribute-ios-applications-without-app-store">How is it even possible to distribute iOS applications without App Store</a> 105 <ul> 106 <li><a href="#building-the-application">Building the application</a></li> 107 <li><a href="#the-manifestplist">The manifest.plist</a></li> 108 <li><a href="#the-itms-services-uri">
108The itms-services URI</a></li> 109 </ul> 110 </li> 111 <li><a href="#caveats-and-other-apple-specifics">Caveats and other Apple specifics</a> 112 <ul> 113 <li><a href="#authenticationauthorization">Authentication/authorization</a> 114 <ul> 115 <li><a href="#both-routes-are-protected">Both routes are protected</a></li> 116 <li><a href="#only-the-application-file-route-is-protected">Only the application file route is protected</a></li> 117 <li><a href="#both-routes-are-not-protected">Both routes are not protected</a></li> 118 </ul> 119 </li> 120 <li><a href="#launching-downloaded-applications-on-ios-devices">Launching downloaded applications on iOS devices</a></li> 121 <li><a href="#limited-number-of-devices">Limited number of devices</a></li> 122 <li><a href="#applications-expiration">Applications expiration</a></li> 123 </ul> 124 </li> 125 <li><a href="#hosting-ios-applications-on-a-netmvc-website">Hosting iOS applications on a .NET/MVC website</a> 126 <ul> 127 <li><a href="#template-string-for-manifestplist">Template string for manifest.plist</a></li> 128 <li><a href="#initial-download-request">Initial download request</a></li> 129 <li><a href="#downloading-the-manifestplist">Downloading the manifest.plist</a></li> 130 <li><a href="#downloading-the-application">Downloading the application</a></li> 131 </ul> 132 </li> 133 </ul> 134</nav> 135<h1 id="why-would-one-need-app-center-or-a-similar-service">Why would one need App Center or a similar service</h1> 136<p>Because fucking Apple didn’t make it simple to distribute iOS applications. You cannot just place an <a href="https://en.wikipedia.org/wiki/.ipa" rel="external">.ipa</a> binary on your website, so users could download and install it.</p> 137<p>Well, actually, you can, and that is what this article is about, but more on that <a href="/blog/2025/04/03/distributing-ios-applications-without-app-store/#how-is-it-even-possible-to-distribute-ios-applications-without-app-store">a bit later</a>. For now, assuming that it’s all black magic to you and that you don’t want to touch App Store / TestFlight with a 100 meters long pole, the only possibility one might have is to use a special service that could host <code>.ipa</code> binaries and perform required voodoo passes to make them installable on users devices. And App Center is one of such services. Was.</p> 138<p>To clarify, here I am talking about distributing “internal” iOS applications within a company, so this is not about publishing applications for general public - for that, I imagine, you would still need to submerge yourself into the great marsh of App Store / TestFlight things, so if that is your case, then this article will likely be of no use for you.</p> 139<h2 id="alternatives">Alternatives</h2> 140<p>Having announced the retirement of App Center, Microsoft <a href="https://learn.microsoft.com/en-us/appcenter/retirement#alternative-solutions" rel="external">suggested</a> App Store and TestFlight as alternatives, but we were using (<em>HockeyApp and then</em>) App Center exactly because we did not want to deal with App Store and TestFlight, so that suggestion wasn’t helpful at all.</p> 141<p>We did find some other alternatives (<em>the list is sorted chronologically - when we discovered those services, starting from oldest</em>):</p> 142<ul> 143<li><a href="https://firebase.google.com/docs/app-distribution" rel="external">Firebase App Distribution</a></li> 144<li><a href="https://docs.appcircle.io/distribute/create-or-select-a-distribution-profile" rel="external">Appcircle</a></li> 145<li><a href="https://getupdraft.com" rel="external">Updraft</a></li> 146<li><a href="https://applivery.com" rel="external">Applivery</a></li> 147<li><a href="https://buildstash.com" rel="external">Buildstash</a> (<em>here’s also their <a href="https://blog.buildstash.com/visual-studio-app-center-closes-what-now/" rel="external">blog post</a> about the situation with some more history and details</em>)</li> 148</ul> 149<p>but neither of them looked like an improvement over App Center. If anything, they look considerably more complicated to deal with than App Center. And there probably is some pricing involved too (<em>App Center was free of charge</em>).</p> 150<p>If I was forced to choose from this list, I’d probably go with Buildstash, but for our single need of simply distributing iOS applications their lots of other functionality would be a total overkill.</p> 151<p>There is actually one more alternative - <a href="https://distapp.lhf.my.id" rel="external">DistApp</a> - which seems to be providing exactly that one functionality that we need, but by the moment we saw that service, we already had implemented our own solution.</p> 152<h2 id="app-center-itself-wasnt-great-either">App Center itself wasn’t great either</h2> 153<p>We actually started slowly looking for an alternative to App Center at least a couple of years ago, so way before its retirement was announced, as over time we eventually came to realization that for us App Center was quite an inconvenient way of distributing applications. Namely, because of:</p> 154<ul> 155<li>the process of downloading/installing the applications was so not intuitive to users, we had to create a step-by-step tutorial (<em>even a video</em>) for that;</li> 156<li>at some point it became impossible to create a new account with a bare e-mail, and they started to force users into using one of the OAuth providers;</li> 157<li>not very well-defined access management: 158<ul> 159<li>applications can have users;</li> 160<li>but there are also distribution groups, which also can have users;</li> 161<li>at the same time, every version of the same application can have different distribution groups;</li> 162<li>it is quite difficult to keep track of all that and have an overview of who has access to what.</li> 163</ul> 164</li> 165</ul> 166<p>Out of those the worst was the access management. Aside from the already listed points, there was one other issue - despite the fact that App Center belongs to Microsoft and even provides a SAML SSO login, and that it even has an option to “integrate” with Azure AD - none of that provides any means for access management (<em>or anything useful, really</em>). For example, every time there was a new user who needed to be granted access to an application, administrator needed to do that manually - that scales very poorly.</p> 167<p>A <a href="https://github.com/microsoft/appcenter/issues/300" rel="external">related request</a> was reported in their repository back in 2019, but got no development whatsoever over all these years.</p> 168<p>So we weren’t happy with App Center, but we were tolerating it, as the service was provided free of charge and it worked quite well for the essential part of distributing iOS applications. But now has come the time to find something else.</p> 169<h1 id="how-is-it-even-possible-to-distribute-ios-applications-without-app-store">How is it even possible to distribute iOS applications without App Store</h1> 170<p>But how is it even possible to distribute iOS applications without App Store / TestFlight? Did (<em>HockeyApp and</em>) App Center do something shady?</p> 171<p>As we learned (<em>should’ve googled that years ago</em>), there is nothing shady about it - that is a very <a href="https://support.apple.com/guide/deployment/distribute-proprietary-in-house-apps-depce7cefc4d/web#dep30d16db76" rel="external">official feature</a>, which meant to be used exactly for distributing internal applications via one’s own website. You just need to know about it.</p> 172<p>In short, all that is required (<em>aside from having Apple Developer (Enterprise?) account</em>) is:</p> 173<ul> 174<li>a particular <code>manifest.plist</code> file;</li> 175<li>a URI with <code>itms-services://</code> scheme.</li> 176</ul> 177<p>The “Enterprise” part I am not sure about, and in general my knowledge about these Apple certificates/profiles/things is very, very vague. You certainly(?) do need to have an <a href="https://developer.apple.com/account/" rel="external">Apple Developer</a> account (<em>99 USD per year?</em>), but from my understanding that needs to be not just a “regular” account but one that belongs to <a href="https://developer.apple.com/programs/enterprise/" rel="external">Apple Developer Enterprise Program</a> (<em>299 USD per year?</em>). From what I read in the eligibility requirements, our company does not qualify to have that, and yet it seems that we do have it somehow. I really cannot say for sure and I have no idea where to check this.</p> 178<p>But assuming that your company has all the required memberships/certificates/whatever, the technical details are the following.</p> 179<h2 id="building-the-application">Building the application</h2> 180<p>Speaking about fucking Apple not making things simple, before you can build your application you need to prepare quite a lot of things, which include getting signing/distribution <a href="https://developer.apple.com/help/account/certificates/certificates-overview" rel="external">certificates</a>, <a href="https://developer.apple.com/help/account/provisioning-profiles/create-a-development-provisioning-profile" rel="external">provisioning profiles</a> and god knows what else.</p> 181<p>The entire procedure is so convoluted that I won’t be able to reproduce it even having done everything just 5 minutes ago. I am not even sure that the links that I provided are the correct ones. We even have a special “iOS guy” in the team, and he is the only one who knows how to set things up. On a good day.</p> 182<p>When you have all the signing-related things in order, then you can produce an <code>.ipa</code> binary like this:</p> 183<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sh" data-lang="sh"><span style="display:flex;"><span>$ cd /path/to/your/application/project/ 184</span></span><span style="display:flex;"><span> 185</span></span><span style="display:flex;"><span><span style="color:#75715e">
185# our applications Xcode projects are generated with CMake (as they are cross-platform),</span> 186</span></span><span style="display:flex;"><span><span style="color:#75715e"># so you can skip this step</span> 187</span></span><span style="display:flex;"><span>$ mkdir build <span style="color:#f92672">&&</span> cd $_ 188</span></span><span style="display:flex;"><span>$ cmake -G Xcode .. 189</span></span><span style="display:flex;"><span> 190</span></span><span style="display:flex;"><span>$ xcodebuild <span style="color:#ae81ff">\ 191</span></span></span><span style="display:flex;"><span> -scheme SomeApplication archive <span style="color:#ae81ff">\ 192</span></span></span><span style="display:flex;"><span> -archivePath SomeApplication.xcarchive <span style="color:#ae81ff">\ 193</span></span></span><span style="display:flex;"><span> -allowProvisioningUpdates <span style="color:#ae81ff">\ 194</span></span></span><span style="display:flex;"><span> -destination <span style="color:#e6db74">"generic/platform=iOS"</span> 195</span></span><span style="display:flex;"><span> 196</span></span><span style="display:flex;"><span>$ xcodebuild <span style="color:#ae81ff">\ 197</span></span></span><span style="display:flex;"><span> -exportArchive <span style="color:#ae81ff">\ 198</span></span></span><span style="display:flex;"><span> -exportPath ./ipa <span style="color:#ae81ff">\ 199</span></span></span><span style="display:flex;"><span> -archivePath <span style="color:#e6db74">"SomeApplication.xcarchive"</span> <span style="color:#ae81ff">\ 200</span></span></span><span style="display:flex;"><span> -exportOptionsPlist ../export-options.plist <span style="color:#ae81ff">\ 201</span></span></span><span style="display:flex;"><span> -allowProvisioningUpdates 202</span></span></code></pre></div><p>where <code>export-options.plist</code> contains the following:</p> 203<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-xml" data-lang="xml"><span style="display:flex;"><span><span style="color:#75715e"><!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"></span> 204</span></span><span style="display:flex;"><span><span style="color:#f92672"><plist</span> <span style="color:#a6e22e">version=</span><span style="color:#e6db74">"1.0"</span><span style="color:#f92672">></span> 205</span></span><span style="display:flex;"><span><span style="color:#f92672"><dict></span> 206</span></span><span style="display:flex;"><span> <span style="color:#f92672"><key></span>method<span style="color:#f92672"></key></span> 207</span></span><span style="display:flex;"><span> <span style="color:#f92672"><string></span>enterprise<span style="color:#f92672"></string></span> 208</span></span><span style="display:flex;"><span> <span style="color:#f92672"><key></span>teamID<span style="color:#f92672"></key></span> 209</span></span><span style="display:flex;"><span> <span style="color:#f92672"><string></span>SOME-ID-HERE<span style="color:#f92672"></string></span> 210</span></span><span style="display:flex;"><span><span style="color:#f92672"></dict></span> 211</span></span><span style="display:flex;"><span><span style="color:#f92672"></plist></span> 212</span></span></code></pre></div><p>As a result you will have a <code>SomeApplication.ipa</code> file, which you will need to upload to your server. Nothing special about that procedure, just a regular file upload via SFTP or whatever you use for that. Although you will probably also want to protect that file with authentication/authorization, but more on that <a href="/blog/2025/04/03/distributing-ios-applications-without-app-store/#authenticationauthorization">later</a>.</p> 213<h2 id="the-manifestplist">The manifest.plist</h2> 214<p>Next thing you need is a special <code>manifest.plist</code> file. For example, if the application name is <code>LidarScanner</code>, then <code>manifest.plist</code> will have the following contents:</p> 215<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-xml" data-lang="xml"><span style="display:flex;"><span><span style="color:#75715e"><?xml version="1.0" encoding="UTF-8"?></span> 216</span></span><span style="display:flex;"><span><span style="color:#75715e"><!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"></span> 217</span></span><span style="display:flex;"><span><span style="color:#f92672"><plist</span> <span style="color:#a6e22e">version=</span><span style="color:#e6db74">"1.0"</span><span style="color:#f92672">></span> 218</span></span><span style="display:flex;"><span><span style="color:#f92672"><dict></span> 219</span></span><span style="display:flex;"><span> <span style="color:#f92672"><key></span>items<span style="color:#f92672"></key></span> 220</span></span><span style="display:flex;"><span> <span style="color:#f92672"><array></span> 221</span></span><span style="display:flex;"><span>
221 <span style="color:#f92672"><dict></span> 222</span></span><span style="display:flex;"><span> <span style="color:#f92672"><key></span>assets<span style="color:#f92672"></key></span> 223</span></span><span style="display:flex;"><span> <span style="color:#f92672"><array></span> 224</span></span><span style="display:flex;"><span> <span style="color:#f92672"><dict></span> 225</span></span><span style="display:flex;"><span> <span style="color:#f92672"><key></span>kind<span style="color:#f92672"></key></span> 226</span></span><span style="display:flex;"><span> <span style="color:#f92672"><string></span>software-package<span style="color:#f92672"></string></span> 227</span></span><span style="display:flex;"><span> <span style="color:#f92672"><key></span>url<span style="color:#f92672"></key></span> 228</span></span><span style="display:flex;"><span> <span style="color:#f92672"><string></span>https://your.company.com/downloads/lidar-scanner.ipa<span style="color:#f92672"></string></span> 229</span></span><span style="display:flex;"><span> <span style="color:#f92672"></dict></span> 230</span></span><span style="display:flex;"><span> <span style="color:#f92672"><dict></span> 231</span></span><span style="display:flex;"><span> <span style="color:#f92672"><key></span>kind<span style="color:#f92672"></key></span> 232</span></span><span style="display:flex;"><span> <span style="color:#f92672"><string></span>display-image<span style="color:#f92672"></string></span> 233</span></span><span style="display:flex;"><span> <span style="color:#f92672"><key></span>url<span style="color:#f92672"></key></span> 234</span></span><span style="display:flex;"><span> <span style="color:#f92672"><string></span>https://your.company.com/favicon-32x32.png<span style="color:#f92672"></string></span> 235</span></span><span style="display:flex;"><span> <span style="color:#f92672"></dict></span> 236</span></span><span style="display:flex;"><span> <span style="color:#f92672"><dict></span> 237</span></span><span style="display:flex;"><span> <span style="color:#f92672"><key></span>kind<span style="color:#f92672"></key></span> 238</span></span><span style="display:flex;"><span> <span style="color:#f92672"><string></span>full-size-image<span style="color:#f92672"></string></span> 239</span></span><span style="display:flex;"><span> <span style="color:#f92672"><key></span>url<span style="color:#f92672"></key></span> 240</span></span><span style="display:flex;"><span> <span style="color:#f92672"><string></span>https://your.company.com/favicon-256x256.png<span style="color:#f92672"></string></span> 241</span></span><span style="display:flex;"><span> <span style="color:#f92672"></dict></span> 242</span></span><span style="display:flex;"><span> <span style="color:#f92672"></array></span> 243</span></span><span style="display:flex;"><span> <span style="color:#f92672"><key></span>metadata<span style="color:#f92672"></key></span> 244</span></span><span style="display:flex;"><span> <span style="color:#f92672"><dict></span> 245</span></span><span style="display:flex;"><span> <span style="color:#f92672"><key></span>bundle-identifier<span style="color:#f92672"></key></span> 246</span></span><span style="display:flex;"><span> <span style="color:#f92672"><string></span>com.company.LidarScanner<span style="color:#f92672"></string></span> 247</span></span><span style="display:flex;"><span> <span style="color:#f92672"><key></span>bundle-version<span style="color:#f92672"></key></span> 248</span></span><span style="display:flex;"><span>
248 <span style="color:#f92672"><string></span>1.0<span style="color:#f92672"></string></span> 249</span></span><span style="display:flex;"><span> <span style="color:#f92672"><key></span>kind<span style="color:#f92672"></key></span> 250</span></span><span style="display:flex;"><span> <span style="color:#f92672"><string></span>software<span style="color:#f92672"></string></span> 251</span></span><span style="display:flex;"><span> <span style="color:#f92672"><key></span>platform-identifier<span style="color:#f92672"></key></span> 252</span></span><span style="display:flex;"><span> <span style="color:#f92672"><string></span>com.apple.platform.iphoneos<span style="color:#f92672"></string></span> 253</span></span><span style="display:flex;"><span> <span style="color:#f92672"><key></span>title<span style="color:#f92672"></key></span> 254</span></span><span style="display:flex;"><span> <span style="color:#f92672"><string></span>LidarScanner<span style="color:#f92672"></string></span> 255</span></span><span style="display:flex;"><span> <span style="color:#f92672"></dict></span> 256</span></span><span style="display:flex;"><span> <span style="color:#f92672"></dict></span> 257</span></span><span style="display:flex;"><span> <span style="color:#f92672"></array></span> 258</span></span><span style="display:flex;"><span><span style="color:#f92672"></dict></span> 259</span></span><span style="display:flex;"><span><span style="color:#f92672"></plist></span> 260</span></span></code></pre></div><p>For this to work you will need to upload the <code>lidar-scanner.ipa</code> binary and this <code>manifest.plist</code> file to <code>/downloads/</code> path in your website root directory on the server. Actually, <code>manifest.plist</code> can be placed anywhere (<em>and it doesn’t even have to be a “physical” file</em>), but the location of <code>lidar-scanner.ipa</code> should match the one specified in the <code><string></code> value for that <code>software-package</code> section in the manifest.</p> 261<p>One more requirement is that the <code>*.ipa</code> URL must be with HTTPS, and the certificate needs to be trusted on the installing device (<em>so you might have some troubles if your SSL/TLS certificate is self-signed</em>).</p> 262<h2 id="the-itms-services-uri">
262The itms-services URI</h2> 263<p>The last thing to do is adding this link anywhere on your website:</p> 264<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sh" data-lang="sh"><span style="display:flex;"><span>itms-services://?action<span style="color:#f92672">=</span>download-manifest&url<span style="color:#f92672">=</span>https://your.company.com/downloads/manifest.plist 265</span></span></code></pre></div><p>So on a HTML page it would be something like this:</p> 266<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-html" data-lang="html"><span style="display:flex;"><span><<span style="color:#f92672">a</span> <span style="color:#a6e22e">href</span><span style="color:#f92672">=</span><span style="color:#e6db74">"itms-services://?action=download-manifest&url=https://your.company.com/downloads/manifest.plist"</span>> 267</span></span><span style="display:flex;"><span> Install iOS application 268</span></span><span style="display:flex;"><span></<span style="color:#f92672">a</span>> 269</span></span></code></pre></div><p>Here it assumes that the <code>manifest.plist</code> file is located at the <code>/downloads/</code> path in your website root directory on the server. If you placed it elsewhere, then obviously you’ll need to adjust that link. And similar to the <code>.ipa</code> URL, it most likely also needs to be with HTTPS (<em>and trusted SSL/TLS certificate</em>).</p> 270<p>Now, once you click on that link in Safari web-browser on one of your iOS devices, the following modal dialog should appear:</p> 271 272 273 <img class="image-post" loading="lazy" src="/blog/2025/04/03/distributing-ios-applications-without-app-store/images/installing-ios-application-from-website-dialog.png" alt="Modal dialog for installing an iOS application from a website"> 274 275<p>and after you click <code>Install</code>, the application will start downloading/installing on your iPhone/iPad.</p> 276<p>This is it, that’s the whole magic - just a <code>manifest.plist</code> file and a <code>itms-services://</code> link.</p> 277<h1 id="caveats-and-other-apple-specifics">Caveats and other Apple specifics</h1> 278<p>It was never Apple’s intention to make developers life easier, so be prepared for various caveats, nuances and limitations.</p> 279<h2 id="authenticationauthorization">Authentication/authorization</h2> 280<p>You most likely do not want your applications to be available to anyone on the internet, so there definitely should be some authentication on your website (<em>unless it’s an internal in-house website, but even then it should probably still have authentication in place</em>). Having said that, the next thing I’ll say is that actually your applications kind of will be available to anyone on the internet!</p> 281<p>When I was investigating how the downloads from App Center work, I discovered that the <code>*.ipa</code> files were in fact available for downloading <u>without authentication</u>. In reality it is not as scary as it sounds, because the generated download URLs look like this:</p> 282<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sh" data-lang="sh"><span style="display:flex;"><span>https://appcenter-filemanagement-distrib2ede6f06e.azureedge.net/dlz9e271-7016-61va-9640-ac0b1k457der/SomeApplication.ipa?sv<span style="color:#f92672">=</span>2025-02-01&sr<span style="color:#f92672">=</span>c&sig<span style="color:#f92672">=</span>YPKVqmfXpecBzveG1nWDPnd4Lh37XM%2F2pLM6D0GWX%2Bg%3D&se<span style="color:#f92672">=</span>2025-03-20T15%3A05%3A50Z&sp<span style="color:#f92672">=</span>r&download_origin<span style="color:#f92672">=</span>appcenter 283</span></span></code></pre></div><p>so it won’t be trivial to guess that long GUID, which probably can count as a kind of “credentials”. Moreover, these URLs have an expiration time, so eventually they will expire and will be returning <code>403</code>.</p> 284<p>But while the URL was still valid, I tested the download from a different device in a private browser tab (<em>where I was not authenticated on App Center</em>), and I also tried it with a bare cURL from a desktop PC, even with a different internet provider (<em>to make sure that I have a different IP address</em>), and it still succeeded:</p> 285<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sh" data-lang="sh"><span style="display:flex;"><span>$ curl -sI <span style="color:#e6db74">'https://appcenter-filemanagement-distrib2ede6f06e.azureedge.net/dlz9e271-7016-61va-9640-ac0b1k45
2857der/SomeApplication.ipa?sv=2025-02-01&sr=c&sig=YPKVqmfXpecBzveG1nWDPnd4Lh37XM%2F2pLM6D0GWX%2Bg%3D&se=2025-03-20T15%3A05%3A50Z&sp=r&download_origin=appcenter'</span> <span style="color:#ae81ff">\ 286</span></span></span><span style="display:flex;"><span> -H <span style="color:#e6db74">'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:124.0) Gecko/20100101 Firefox/124.0'</span> <span style="color:#ae81ff">\ 287</span></span></span><span style="display:flex;"><span> -H <span style="color:#e6db74">'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.5'</span> <span style="color:#ae81ff">\ 288</span></span></span><span style="display:flex;"><span> -H <span style="color:#e6db74">'Accept-Encoding: gzip, deflate, br'</span> <span style="color:#ae81ff">\ 289</span></span></span><span style="display:flex;"><span> -H <span style="color:#e6db74">'DNT: 1'</span> <span style="color:#ae81ff">\ 290</span></span></span><span style="display:flex;"><span> -H <span style="color:#e6db74">'Connection: keep-alive'</span> <span style="color:#ae81ff">\ 291</span></span></span><span style="display:flex;"><span> | grep -i Content-Length <span style="color:#ae81ff">\ 292</span></span></span><span style="display:flex;"><span> | awk <span style="color:#e6db74">'{print $2/1024/1024 " MB"}'</span> 293</span></span><span style="display:flex;"><span>10.5228 MB 294</span></span></code></pre></div><p>As you can see, it is indeed available with no authentication. So if the generated URL is somehow known/leaked and has not expired yet, then anyone on the internet can download the application.</p> 295<p>But why is it so? Did App Center people do something wrong? Because Apple’s <a href="https://support.apple.com/guide/deployment/distribute-proprietary-in-house-apps-depce7cefc4d/web#dep30d16db76" rel="external">documentation</a> says:</p> 296<blockquote> 297 <p> 298 Make sure that users are <b>authenticated</b> and that the website is accessible from your intranet or the internet, depending on your needs. 299 </p> 300 <p> 301 [...] 302 </p> 303 <p> 304 Upload these items to an area of your website that your <b>authenticated</b> users can access: 305 </p> 306 <ul> 307 <li>The manifest file (with a .plist filename extension)</li> 308 <li>The app file (with a .ipa filename extension)</li> 309 </ul> 310</blockquote> 311<p>…so authentication is explicitly mentioned there. But it turned out to be a <b>goddamn lie</b> (<em>or at least it is for some authentication methods/schemas?</em>), as actually both the <code>manifest.plist</code> and the <code>*.ipa</code> file have to be available without authentication.</p> 312<p>I don’t know why, and at first I thought that this is because Apple needs to query stuff on the website from their servers, but there were no requests from Apple IP addresses in my web server logs (<em>there were only requests from my iOS devices</em>). So then my next guess is that it’s iOS internals who needs to query stuff on the website, and those requests apparently are happening outside of the authenticated Safari session(?), so they are quite naturally failing.</p> 313<p>Specifically, below you can take a look at NGINX logs for requests from my iOS device when I was trying to download an application from our website.</p> 314<h3 id="both-routes-are-protected">Both routes are protected</h3> 315<p>When the <code>manifest.plist</code> route is protected with authentication/authorization:</p> 316<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sh" data-lang="sh"><span style="display:flex;"><span>MY-IOS-DEVICE-IP-ADDRESS - - <span style="color:#f92672">[</span>02/Feb/2025:14:37:02 +0100<span style="color:#f92672">]</span> <span style="color:#e6db74">"GET /files/applications/lidar-scanner/v1.3.42/manifest.plist HTTP/1.1"</span> <span style="color:#ae81ff">302</span> <span style="color:#ae81ff">0</span> <span style="color:#e6db74">"https://your.company.com/applications/lidar-scanner"</span> <span style="color:#e6db74">"Mozilla/5.0 (iPhone; CPU iPhone OS 18_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3 Mobile/15E148 Safari/604.1"</span> 317</span></span><span style="display:flex;"><span>
317MY-IOS-DEVICE-IP-ADDRESS - - <span style="color:#f92672">[</span>02/Feb/2025:14:37:04 +0100<span style="color:#f92672">]</span> <span style="color:#e6db74">"GET /downloads/applications/lidar-scanner/v1.3.42/ipa/694277a0-a42c-4f83-a8e9-f74207d217d1/manifest.plist HTTP/1.1"</span> <span style="color:#ae81ff">302</span> <span style="color:#ae81ff">0</span> <span style="color:#e6db74">"-"</span> <span style="color:#e6db74">"com.apple.appstored/1.0 iOS/18.3 model/iPhone16,2 hwp/t8130 build/22D63 (6; dt:311) AMS/1"</span> 318</span></span><span style="display:flex;"><span>MY-IOS-DEVICE-IP-ADDRESS - - <span style="color:#f92672">[</span>02/Feb/2025:14:37:04 +0100<span style="color:#f92672">]</span> <span style="color:#e6db74">"GET /Account/Login?ReturnUrl=%2Fdownloads%2Fapplications%2Flidar-scanner%2Fv1.3.42%2Fipa%2F694277a0-a42c-4f83-a8e9-f74207d217d1%2Fmanifest.plist HTTP/1.1"</span> <span style="color:#ae81ff">200</span> <span style="color:#ae81ff">1581</span> <span style="color:#e6db74">"-"</span> <span style="color:#e6db74">"com.apple.appstored/1.0 iOS/18.3 model/iPhone16,2 hwp/t8130 build/22D63 (6; dt:311) AMS/1"</span> 319</span></span></code></pre></div><p>then it doesn’t even get to the <code>*.ipa</code> route, which is also protected with authentication/authorization, but it didn’t matter in this case, as it failed already on getting the <code>manifest.plist</code> and got redirected to the login route, which results in nothing, as these requests are happening behind the scenes (<em>so user gets no prompts</em>).</p> 320<h3 id="only-the-application-file-route-is-protected">Only the application file route is protected</h3> 321<p>Then I removed authentication/authorization requirement from the <code>manifest.plist</code> route but kept it for the <code>*.ipa</code> route, and for that scenario I got the following logs:</p> 322<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sh" data-lang="sh"><span style="display:flex;"><span>MY-IOS-DEVICE-IP-ADDRESS - - <span style="color:#f92672">[</span>02/Feb/2025:14:40:21 +0100<span style="color:#f92672">]</span> <span style="color:#e6db74">"GET /files/applications/lidar-scanner/v1.3.42/manifest.plist HTTP/1.1"</span> <span style="color:#ae81ff">302</span> <span style="color:#ae81ff">0</span> <span style="color:#e6db74">"https://your.company.com/applications/lidar-scanner"</span> <span style="color:#e6db74">"Mozilla/5.0 (iPhone; CPU iPhone OS 18_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3 Mobile/15E148 Safari/604.1"</span> 323</span></span><span style="display:flex;"><span>MY-IOS-DEVICE-IP-ADDRESS - - <span style="color:#f92672">[</span>02/Feb/2025:14:40:21 +0100<span style="color:#f92672">]</span> <span style="color:#e6db74">"GET /downloads/applications/lidar-scanner/v1.3.42/ipa/694277a0-a42c-4f83-a8e9-f74207d217d1/manifest.plist HTTP/1.1"</span> <span style="color:#ae81ff">200</span> <span style="color:#ae81ff">496</span> <span style="color:#e6db74">"-"</span> <span style="color:#e6db74">"com.apple.appstored/1.0 iOS/18.3 model/iPhone16,2 hwp/t8130 build/22D63 (6; dt:311) AMS/1"</span> 324</span></span><span style="display:flex;"><span>MY-IOS-DEVICE-IP-ADDRESS - - <span style="color:#f92672">[</span>02/Feb/2025:14:40:23 +0100<span style="color:#f92672">]</span> <span style="color:#e6db74">"HEAD /downloads/applications/lidar-scanner/v1.3.42/ipa/694277a0-a42c-4f83-a8e9-f74207d217d1 HTTP/1.1"</span> <span style="color:#ae81ff">302</span> <span style="color:#ae81ff">0</span> <span style="color:#e6db74">"-"</span> <span style="color:#e6db74">"com.apple.appstored/1.0 iOS/18.3 model/iPhone16,2 hwp/t8130 build/22D63 (6; dt:311) AMS/1"</span> 325</span></span><span style="display:flex;"><span>MY-IOS-DEVICE-IP-ADDRESS - - <span style="color:#f92672">[</span>02/Feb/2025:14:40:23 +0100<span style="color:#f92672">]</span> <span style="color:#e6db74">"HEAD /Account/Login?ReturnUrl=%2Fdownloads%2Fapplications%2Flidar-scanner%2Fv1.3.42%2Fipa%2F694277a0-a42c-4f83-a8e9-f74207d217d1 HTTP/1.1"</span> <span style="color:#ae81ff">405</span> <span style="color:#ae81ff">0</span> <span style="color:#e6db74">"-"</span> <span style="color:#e6db74">"com.apple.appstored/1.0 iOS/18.3 model/iPhone16,2 hwp/t8130 build/22D63 (6; dt:311) AMS/1"</span> 326</span></span><span style="display:flex;"><span>
326MY-IOS-DEVICE-IP-ADDRESS - - <span style="color:#f92672">[</span>02/Feb/2025:14:40:23 +0100<span style="color:#f92672">]</span> <span style="color:#e6db74">"GET /favicon-32x32.png HTTP/1.1"</span> <span style="color:#ae81ff">200</span> <span style="color:#ae81ff">2004</span> <span style="color:#e6db74">"-"</span> <span style="color:#e6db74">"com.apple.appstored/1.0 iOS/18.3 model/iPhone16,2 hwp/t8130 build/22D63 (6; dt:311) AMS/1"</span> 327</span></span><span style="display:flex;"><span>MY-IOS-DEVICE-IP-ADDRESS - - <span style="color:#f92672">[</span>02/Feb/2025:14:40:23 +0100<span style="color:#f92672">]</span> <span style="color:#e6db74">"GET /favicon-32x32.png HTTP/1.1"</span> <span style="color:#ae81ff">200</span> <span style="color:#ae81ff">2004</span> <span style="color:#e6db74">"-"</span> <span style="color:#e6db74">"com.apple.appstored/1.0 iOS/18.3 model/iPhone16,2 hwp/t8130 build/22D63 (6; dt:311) AMS/1"</span> 328</span></span><span style="display:flex;"><span>MY-IOS-DEVICE-IP-ADDRESS - - <span style="color:#f92672">[</span>02/Feb/2025:14:40:23 +0100<span style="color:#f92672">]</span> <span style="color:#e6db74">"HEAD /downloads/applications/lidar-scanner/v1.3.42/ipa/694277a0-a42c-4f83-a8e9-f74207d217d1 HTTP/1.1"</span> <span style="color:#ae81ff">302</span> <span style="color:#ae81ff">0</span> <span style="color:#e6db74">"-"</span> <span style="color:#e6db74">"com.apple.appstored/1.0 iOS/18.3 model/iPhone16,2 hwp/t8130 build/22D63 (6; dt:311) AMS/1"</span> 329</span></span><span style="display:flex;"><span>MY-IOS-DEVICE-IP-ADDRESS - - <span style="color:#f92672">[</span>02/Feb/2025:14:40:23 +0100<span style="color:#f92672">]</span> <span style="color:#e6db74">"HEAD /Account/Login?ReturnUrl=%2Fdownloads%2Fapplications%2Flidar-scanner%2Fv1.3.42%2Fipa%2F694277a0-a42c-4f83-a8e9-f74207d217d1 HTTP/1.1"</span> <span style="color:#ae81ff">405</span> <span style="color:#ae81ff">0</span> <span style="color:#e6db74">"-"</span> <span style="color:#e6db74">"com.apple.appstored/1.0 iOS/18.3 model/iPhone16,2 hwp/t8130 build/22D63 (6; dt:311) AMS/1"</span> 330</span></span><span style="display:flex;"><span>MY-IOS-DEVICE-IP-ADDRESS - - <span style="color:#f92672">[</span>02/Feb/2025:14:40:23 +0100<span style="color:#f92672">]</span> <span style="color:#e6db74">"GET /downloads/applications/lidar-scanner/v1.3.42/ipa/694277a0-a42c-4f83-a8e9-f74207d217d1 HTTP/1.1"</span> <span style="color:#ae81ff">302</span> <span style="color:#ae81ff">0</span> <span style="color:#e6db74">"-"</span> <span style="color:#e6db74">"com.apple.appstored/1.0 iOS/18.3 model/iPhone16,2 hwp/t8130 build/22D63 (6; dt:311) AMS/1"</span> 331</span></span><span style="display:flex;"><span>MY-IOS-DEVICE-IP-ADDRESS - - <span style="color:#f92672">[</span>02/Feb/2025:14:40:23 +0100<span style="color:#f92672">]</span> <span style="color:#e6db74">"GET /Account/Login?ReturnUrl=%2Fdownloads%2Fapplications%2Flidar-scanner%2Fv1.3.42%2Fipa%2F694277a0-a42c-4f83-a8e9-f74207d217d1 HTTP/1.1"</span> <span style="color:#ae81ff">200</span> <span style="color:#ae81ff">1565</span> <span style="color:#e6db74">"-"</span> <span style="color:#e6db74">"com.apple.appstored/1.0 iOS/18.3 model/iPhone16,2 hwp/t8130 build/22D63 (6; dt:311) AMS/1"</span> 332</span></span></code></pre></div><p>So this time it:</p> 333<ol> 334<li>Succeeded with the <code>manifest.plist</code> route, as it is now available for unauthenticated requests;</li> 335<li>Tried to query the <code>*.ipa</code> route with <code>HEAD</code>, which failed the same way and again got redirected to the login route (<em>and then failed there with <code>405</code>, because that route doesn’t allow <code>HEAD</code> requests in our backend</em>);</li> 336<li>However, it didn’t stop there, as then it fetched one of the images specified in the manifest (<em>twice, for some reason</em>);</li> 337<li>Then it tried to send the same <code>HEAD</code> request again, with the same unsuccessful result, and apparently it still didn’t matter; 338<ul> 339<li>so what is the point of those <code>HEAD</code> requests, if their (<em>double</em>) failure does not interrupt the process?</li> 340</ul> 341</li> 342<li>And then seemingly it actually did try to <code>GET</code> the application’s <code>*.ipa</code> file, but that also resulted in a redirect to the login route, where it finally halted.</li> 343</ol> 344<p>On the iOS device though I got the application icon, but with a cloud symbol in front of the title, and trying to open it I got this useless error message:</p> 345 346 347 <img class="image-post" loading="lazy" src="/blog/2025/04/03/distributing-ios-applications-without-app-store/images/ipa-route-authentication.png" alt="Description"> 348 349<h3 id="both-routes-are-not-protected">Both routes are not protected</h3> 350<p>And only when I removed the authentication/authorization requirement from both the <code>manifest.plist</code> and the <code>*.ipa</code> routes, then everything succeeded (<em>but still queried the same image twice, for some reason</em>):</p> 351<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sh" data-lang="sh"><span style="display:flex;"><span>
351MY-IOS-DEVICE-IP-ADDRESS - - <span style="color:#f92672">[</span>02/Feb/2025:15:15:28 +0100<span style="color:#f92672">]</span> <span style="color:#e6db74">"GET /files/applications/lidar-scanner/v1.3.42/manifest.plist HTTP/1.1"</span> <span style="color:#ae81ff">302</span> <span style="color:#ae81ff">0</span> <span style="color:#e6db74">"https://your.company.com/applications/lidar-scanner"</span> <span style="color:#e6db74">"Mozilla/5.0 (iPhone; CPU iPhone OS 18_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3 Mobile/15E148 Safari/604.1"</span> 352</span></span><span style="display:flex;"><span>MY-IOS-DEVICE-IP-ADDRESS - - <span style="color:#f92672">[</span>02/Feb/2025:15:15:28 +0100<span style="color:#f92672">]</span> <span style="color:#e6db74">"GET /downloads/applications/lidar-scanner/v1.3.42/ipa/97707e38-079e-4844-9cbf-67cf3c4584c1/manifest.plist HTTP/1.1"</span> <span style="color:#ae81ff">200</span> <span style="color:#ae81ff">497</span> <span style="color:#e6db74">"-"</span> <span style="color:#e6db74">"com.apple.appstored/1.0 iOS/18.3 model/iPhone16,2 hwp/t8130 build/22D63 (6; dt:311) AMS/1"</span> 353</span></span><span style="display:flex;"><span>MY-IOS-DEVICE-IP-ADDRESS - - <span style="color:#f92672">[</span>02/Feb/2025:15:15:31 +0100<span style="color:#f92672">]</span> <span style="color:#e6db74">"HEAD /downloads/applications/lidar-scanner/v1.3.42/ipa/97707e38-079e-4844-9cbf-67cf3c4584c1 HTTP/1.1"</span> <span style="color:#ae81ff">200</span> <span style="color:#ae81ff">0</span> <span style="color:#e6db74">"-"</span> <span style="color:#e6db74">"com.apple.appstored/1.0 iOS/18.3 model/iPhone16,2 hwp/t8130 build/22D63 (6; dt:311) AMS/1"</span> 354</span></span><span style="display:flex;"><span>MY-IOS-DEVICE-IP-ADDRESS - - <span style="color:#f92672">[</span>02/Feb/2025:15:15:31 +0100<span style="color:#f92672">]</span> <span style="color:#e6db74">"GET /favicon-32x32.png HTTP/1.1"</span> <span style="color:#ae81ff">200</span> <span style="color:#ae81ff">
3542004</span> <span style="color:#e6db74">"-"</span> <span style="color:#e6db74">"com.apple.appstored/1.0 iOS/18.3 model/iPhone16,2 hwp/t8130 build/22D63 (6; dt:311) AMS/1"</span> 355</span></span><span style="display:flex;"><span>MY-IOS-DEVICE-IP-ADDRESS - - <span style="color:#f92672">[</span>02/Feb/2025:15:15:31 +0100<span style="color:#f92672">]</span> <span style="color:#e6db74">"GET /favicon-32x32.png HTTP/1.1"</span> <span style="color:#ae81ff">200</span> <span style="color:#ae81ff">2004</span> <span style="color:#e6db74">"-"</span> <span style="color:#e6db74">"com.apple.appstored/1.0 iOS/18.3 model/iPhone16,2 hwp/t8130 build/22D63 (6; dt:311) AMS/1"</span> 356</span></span><span style="display:flex;"><span>MY-IOS-DEVICE-IP-ADDRESS - - <span style="color:#f92672">[</span>02/Feb/2025:15:15:31 +0100<span style="color:#f92672">]</span> <span style="color:#e6db74">"GET /downloads/applications/lidar-scanner/v1.3.42/ipa/97707e38-079e-4844-9cbf-67cf3c4584c1 HTTP/1.1"</span> <span style="color:#ae81ff">200</span> <span style="color:#ae81ff">11874414</span> <span style="color:#e6db74">"-"</span> <span style="color:#e6db74">"com.apple.appstored/1.0 iOS/18.3 model/iPhone16,2 hwp/t8130 build/22D63 (6; dt:311) AMS/1"</span> 357</span></span></code></pre></div><p>Thus, the only thing that can have authentication/authorization is the page where you publish the <code>itms-services://</code> link.</p> 358<p>So App Center (<em>and all other similar services?</em>) had no other choice but to implement those generated short-lived unauthenticated download URLs in order not to expose static <code>*.ipa</code> URLs for anonymous downloads. And that is <a href="/blog/2025/04/03/distributing-ios-applications-without-app-store/#initial-download-request">what we did</a> on our side too (<em>as you can see in the web server logs above</em>).</p> 359<h2 id="launching-downloaded-applications-on-ios-devices">Launching downloaded applications on iOS devices</h2> 360<p>After downloading/installing the application you will need to perform a certain ritual in order to be able to launch it. To clarify, this applies no matter what solution you will choose for hosting your applications: it was there with HockeyApp, it was there with App Center, and it will be there with any other similar service, including a self-hosted solution which we are talking about here.</p> 361<p>So, thanks to Apple, the downloaded application cannot be launched right away (<em>unless this device already had other applications from this organization/profile before</em>). Trying to launch it for the first time, you will get the following message (<em>I don’t know why it says <code>iPhone Distribution</code>, but it works equally fine on both iPhones and iPads</em>):</p> 362 363 364 <img class="image-post" loading="lazy" src="/blog/2025/04/03/distributing-ios-applications-without-app-store/images/untrusted-developer.png" alt="Trying to launch iOS application, untrusted enterprise developer"> 365 366<p>What this message means is that you need to go to <code>Settings</code> → <code>General</code> and there will be this <code>ENTERPRISE APP</code> thing (<em>the last section of the <code>General</code> pane</em>):</p> 367 368 369 <img class="image-post" loading="lazy" src="/blog/2025/04/03/distributing-ios-applications-without-app-store/images/ipad-settings-enterprise-app.png" alt="Enterprise applications in iPad settings"> 370 371<p>In there there will be a button/label for trusting this profile:</p> 372 373 374 <img class="image-post" loading="lazy" src="/blog/2025/04/03/distributing-ios-applications-without-app-store/images/ipad-settings-enterprise-app-trust.png" alt="Enterprise applications in iPad settings, trusting button"> 375 376<p>pressing on which will give you one final confirmation dialog:</p> 377 378 379 <img class="image-post" loading="lazy" src="/blog/2025/04/03/distributing-ios-applications-without-app-store/images/ipad-settings-enterprise-app-trust-confirmation.png" alt="Enterprise applications in iPad settings, trust confirmation"> 380 381<p>After that you will finally be able to launch the application. I can admit that this is not a terrible security measure against potentially malicious applications, but it is quite tiresome nevertheless.</p> 382<p>And you guessed it right, you will need to teach/explain this procedure to every single user/employee that you have. It might not look like much to you now, but wait until you’ll be helping with the same bloody set of problems for the 1000th time, as no one ever fucking reads any manuals
382/tutorials. You can even make a super detailed step-by-step video (<em>we did, in addition to the one we already had for downloading applications from App Center</em>), but even then there will be individuals who will manage to fail to follow the procedure.</p> 383<h2 id="limited-number-of-devices">Limited number of devices</h2> 384<p>From the little I know about the Apple side of things, there are different “types” of distribution. If I am not mistaken, the most common ones are a so-called “Ad Hoc” distribution and a so-called “Enterprise” distribution. Both of these seem to have different limitations, but for the love of god I could not find a clear explanation in Apple documentation. So the following details are just my silly understanding of what I could find all over the internet.</p> 385<p>With “Ad Hoc” type of distribution there seems to be a limit of 100 devices of each type (<em>100 iPhones, 100 iPads, etc</em>) to which you can install your applications. And each of those devices (<em>their unique identifiers - UDID</em>) has to be <a href="https://developer.apple.com/documentation/xcode/distributing-your-app-to-registered-devices#Collect-device-identifiers-iOS-iPadOS-tvOS-visionOS-watchOS" rel="external">registered</a> to your Apple Developer account <em>before</em> you’ll try to install/run your applications on it. Or at least I read something about that somewhere, probably <a href="https://developer.apple.com/help/account/provisioning-profiles/create-an-ad-hoc-provisioning-profile/" rel="external">here</a>.</p> 386<p>With the “Enterprise” type of distribution (<em>which is apparently the one we have</em>) those limitations do not apply, as we just keep installing our iOS applications on more and more employees devices without registering shit anywhere (<em>unless it happens automatically behind the scenes?</em>), and nothing “bad” has happened yet, everything keeps working fine. And <a href="https://docs.getupdraft.com/ios/code-signing-explained-certificates-identifiers-profiles-what#distribution-enterprise" rel="external">this section</a> in Updraft documentation seems to be describing the same.</p> 387<h2 id="applications-expiration">Applications expiration</h2> 388<p>With Apple being Apple there is a surprise awaiting for you where you discover that applications expire in one year. Or rather their “<em>provisioning profile</em>” does. So in one year all your users will get a pumpkin instead of an application that was working fine just yesterday:</p> 389 390 391 <img class="image-post" loading="lazy" src="/blog/2025/04/03/distributing-ios-applications-without-app-store/images/integrity-could-not-be-verified.png" alt="Error on trying to launch expired iOS application, integrity could not be verified"> 392 393<p>To fix that you will need to re-build and re-sign the absolutely fucking same application project just to “refresh” the expiration countdown. And then you will need to tell all your users/employees that they need to go to your website and re-install that “renewed” but otherwise absolutely identical application on their devices.</p> 394<p>Again, I don’t remember where I read about this, probably <a href="https://developer.apple.com/library/archive/recipes/ProvisioningPortal_Recipes/CreatingaDistributionProvisioningProfile/CreatingaDistributionProvisioningProfile.html" rel="external">here</a>. Or actually here’s what <a href="https://learn.microsoft.com/en-us/intune/intune-service/apps/app-provisioning-profile-ios" rel="external">Microsoft’s documentation</a> says about this:</p> 395<blockquote>The enterprise signing certificate that you use to sign apps typically lasts for three years. However, the provisioning profile expires after a year. While the certificate is still valid, Intune gives you the tools to proactively assign a new provisioning profile to devices that have apps that are nearing expiry. After the certificate expires, you must sign the app again with a new certificate and embed a new provisioning profile with the key of the new certificate.</blockquote> 396<p>So there is one more surprise: in addition to provisioning profile expiring after 1 year, your signing certificate will also be expiring every 3 years.</p> 397<h1 id="hosting-ios-applications-on-a-netmvc-website">Hosting iOS applications on a .NET/MVC website</h1> 398<p>As I already said in the beginning, now when you know <a href="/blog/2025/04/03/distributing-ios-applications-without-app-store/#how-is-it-even-possible-to-distribute-ios-applications-without-app-store">what it takes</a> to distribute an (<em>“
398Enterprise”</em>) iOS application, it should be rather trivial for you to implement this on your own website, no matter what web-development framework you are using there. In a simplest case even a single static HTML page would do. Or maybe even a bare <code>itms-services://</code> link sent via e-mail should be enough.</p> 399<p>In our case we have a .NET/MVC-based website, so I’ll share the related code fragments. Note that I removed some parts from that code for simplicity/confidentiality, so there might be missing variables or other errors in there. This is just a general example after all, so don’t expect it to work “out of the box”.</p> 400<p>To reiterate the <a href="/blog/2025/04/03/distributing-ios-applications-without-app-store/#authenticationauthorization">key point</a> of having some backend code instead of just a “plain” HTML page: the goal here is to auto-generate short-lived download URLs in order not to expose static <code>*.ipa</code> files without authentication.</p> 401<h2 id="template-string-for-manifestplist">Template string for manifest.plist</h2> 402<p>As you’ll probably have more than one application to distribute, and given that every application will likely have more than one version, it quickly becomes impractical to compose static <code>manifest.plist</code> manually. You could probably auto-generate them with a script every time a new application/version is published, but instead we decided to generate them “on the fly” using a template string:</p> 403<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-xml" data-lang="xml"><span style="display:flex;"><span>private const string _manifestPlist = """ 404</span></span><span style="display:flex;"><span><span style="color:#75715e"><?xml version="1.0" encoding="UTF-8"?></span> 405</span></span><span style="display:flex;"><span><span style="color:#75715e"><!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"></span> 406</span></span><span style="display:flex;"><span><span style="color:#f92672"><plist</span> <span style="color:#a6e22e">version=</span><span style="color:#e6db74">"1.0"</span><span style="color:#f92672">></span> 407</span></span><span style="display:flex;"><span><span style="color:#f92672"><dict></span> 408</span></span><span style="display:flex;"><span> <span style="color:#f92672"><key></span>items<span style="color:#f92672"></key></span> 409</span></span><span style="display:flex;"><span> <span style="color:#f92672"><array></span> 410</span></span><span style="display:flex;"><span> <span style="color:#f92672"><dict></span> 411</span></span><span style="display:flex;"><span> <span style="color:#f92672"><key></span>assets<span style="color:#f92672"></key></span> 412</span></span><span style="display:flex;"><span> <span style="color:#f92672"><array></span> 413</span></span><span style="display:flex;"><span> <span style="color:#f92672"><dict></span> 414</span></span><span style="display:flex;"><span> <span style="color:#f92672"><key></span>kind<span style="color:#f92672"></key></span> 415</span></span><span style="display:flex;"><span> <span style="color:#f92672"><string></span>software-package<span style="color:#f92672"></string></span> 416</span></span><span style="display:flex;"><span> <span style="color:#f92672"><key></span>url<span style="color:#f92672"></key></span> 417</span></span><span style="display:flex;"><span> <span style="color:#f92672"><string></span>https://your.company.com/downloads/applications/{0}/v{1}/ipa/{2}<span style="color:#f92672"></string></span> 418</span></span><span style="display:flex;"><span> <span style="color:#f92672"></dict></span> 419</span></span><span style="display:flex;"><span>
419 <span style="color:#f92672"><dict></span> 420</span></span><span style="display:flex;"><span> <span style="color:#f92672"><key></span>kind<span style="color:#f92672"></key></span> 421</span></span><span style="display:flex;"><span> <span style="color:#f92672"><string></span>display-image<span style="color:#f92672"></string></span> 422</span></span><span style="display:flex;"><span> <span style="color:#f92672"><key></span>url<span style="color:#f92672"></key></span> 423</span></span><span style="display:flex;"><span> <span style="color:#f92672"><string></span>https://your.company.com/favicon-32x32.png<span style="color:#f92672"></string></span> 424</span></span><span style="display:flex;"><span> <span style="color:#f92672"></dict></span> 425</span></span><span style="display:flex;"><span> <span style="color:#f92672"><dict></span> 426</span></span><span style="display:flex;"><span> <span style="color:#f92672"><key></span>kind<span style="color:#f92672"></key></span> 427</span></span><span style="display:flex;"><span> <span style="color:#f92672"><string></span>full-size-image<span style="color:#f92672"></string></span> 428</span></span><span style="display:flex;"><span> <span style="color:#f92672"><key></span>url<span style="color:#f92672"></key></span> 429</span></span><span style="display:flex;"><span> <span style="color:#f92672"><string></span>https://your.company.com/favicon-256x256.png<span style="color:#f92672"></string></span> 430</span></span><span style="display:flex;"><span> <span style="color:#f92672"></dict></span> 431</span></span><span style="display:flex;"><span> <span style="color:#f92672"></array></span> 432</span></span><span style="display:flex;"><span> <span style="color:#f92672"><key></span>metadata<span style="color:#f92672"></key></span> 433</span></span><span style="display:flex;"><span> <span style="color:#f92672"><dict></span> 434</span></span><span style="display:flex;"><span> <span style="color:#f92672"><key></span>bundle-identifier<span style="color:#f92672"></key></span> 435</span></span><span style="display:flex;"><span> <span style="color:#f92672"><string></span>{3}<span style="color:#f92672"></string></span> 436</span></span><span style="display:flex;"><span> <span style="color:#f92672"><key></span>bundle-version<span style="color:#f92672"></key></span> 437</span></span><span style="display:flex;"><span> <span style="color:#f92672"><string></span>1.0<span style="color:#f92672"></string></span> 438</span></span><span style="display:flex;"><span> <span style="color:#f92672"><key></span>kind<span style="color:#f92672"></key></span> 439</span></span><span style="display:flex;"><span> <span style="color:#f92672"><string></span>software<span style="color:#f92672"></string></span> 440</span></span><span style="display:flex;"><span> <span style="color:#f92672"><key></span>platform-identifier<span style="color:#f92672"></key></span> 441</span></span><span style="display:flex;"><span> <span style="color:#f92672"><string></span>com.apple.platform.iphoneos<span style="color:#f92672"></string></span> 442</span></span><span style="display:flex;"><span> <span style="color:#f92672"><key></span>title<span style="color:#f92672"></key></span> 443</span></span><span style="display:flex;"><span> <span style="color:#f92672"><string></span>{4}<span style="color:#f92672"></string></span> 444</span></span><span style="display:flex;"><span> <span style="color:#f92672"></dict></span> 445</span></span><span style="display:flex;"><span>
445 <span style="color:#f92672"></dict></span> 446</span></span><span style="display:flex;"><span> <span style="color:#f92672"></array></span> 447</span></span><span style="display:flex;"><span><span style="color:#f92672"></dict></span> 448</span></span><span style="display:flex;"><span><span style="color:#f92672"></plist></span> 449</span></span><span style="display:flex;"><span>"""; 450</span></span></code></pre></div><p>Not sure if <code>bundle-version</code> value should also be templated or not, but having it hardcoded to <code>1.0</code> didn’t seem to affect anything so far.</p> 451<p>It would probably be more correct to generate the <code>manifest.plist</code> with <a href="https://learn.microsoft.com/en-us/dotnet/api/system.xml.linq.xdocument" rel="external">XDocument</a>, but it didn’t seem to be very trivial in case of Apple’s <code>*.plist</code> files schema, and also performance-wise it is likely (<em>although I haven’t measured it</em>) more efficient to just substitute variables in a template string than to construct a <code>XDocument</code> object every time.</p> 452<h2 id="initial-download-request">Initial download request</h2> 453<p>As we want to pre-process the download request first before redirecting it to the <code>itms-services://</code> URI, the download links on the website look like this:</p> 454<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-html" data-lang="html"><span style="display:flex;"><span><<span style="color:#f92672">a</span> <span style="color:#a6e22e">href</span><span style="color:#f92672">=</span><span style="color:#e6db74">"/files/applications/some-application/v1.2.3/manifest.plist"</span>> 455</span></span><span style="display:flex;"><span> Install 456</span></span><span style="display:flex;"><span></<span style="color:#f92672">a</span>> 457</span></span></code></pre></div><p>And here’s the route/action for processing the download request when user clicks on that link:</p> 458<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#75715e">// note the absence of the [AllowAnonymous] attribute, as this action is protected</span> 459</span></span><span style="display:flex;"><span><span style="color:#75715e">// with authentication/authorization policies on the controller level</span> 460</span></span><span style="display:flex;"><span><span style="color:#a6e22e">[HttpGet("/files/applications/{applicationSlug}/v{applicationVersion}/manifest.plist")]</span> 461</span></span><span style="display:flex;"><span><span style="color:#66d9ef">public</span> IActionResult ApplicationDownloadFile( 462</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">string</span> applicationSlug, 463</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">string</span> applicationVersion 464</span></span><span style="display:flex;"><span>) 465</span></span><span style="display:flex;"><span>{ 466</span></span><span style="display:flex;"><span> <span style="color:#75715e">// check in the database if such application even exists</span> 467</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">var</span> application = _databaseContext.GetApplication(applicationSlug); 468</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">if</span> (application == <span style="color:#66d9ef">null</span>) 469</span></span><span style="display:flex;"><span> { 470</span></span><span style="display:flex;"><span> _logger.Warn(<span style="color:#e6db74">$"There is no [{applicationSlug}] application in the database"</span>); 471</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">return</span> NotFound(); 472</span></span><span style="display:flex;"><span> } 473</span></span><span style="display:flex;"><span> 474</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">var</span> downloadGUID = Guid.NewGuid(); 475</span></span><span style="display:flex;"><span> 476</span></span><span style="display:flex;"><span> <span style="color:#75715e">// check in the database if there is already an anonymous download</span> 477</span></span><span style="display:flex;"><span> <span style="color:#75715e">// for this version of the application (and get its data/info)</span> 478</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">var</span> applicationDownloadAnonymous = _databaseContext.GetApplicationVersionDownloadAnonymous( 479</span></span><span style="display:flex;"><span> applicationSlug, 480</span></span><span style="display:flex;"><span> applicationVersion 481</span></span><span style="display:flex;"><span> ); 482</span></span><span style="display:flex;"><span> 483</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">if</span> ( 484</span></span><span style="display:flex;"><span>
484 <span style="color:#75715e">// no such download at all</span> 485</span></span><span style="display:flex;"><span> applicationDownloadAnonymous == <span style="color:#66d9ef">null</span> 486</span></span><span style="display:flex;"><span> || 487</span></span><span style="display:flex;"><span> <span style="color:#75715e">// there was a download, but it has expired by now</span> 488</span></span><span style="display:flex;"><span> DownloadLinkHasExpired(applicationDownloadAnonymous.Created) 489</span></span><span style="display:flex;"><span> ) 490</span></span><span style="display:flex;"><span> { 491</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">if</span> (applicationDownloadAnonymous == <span style="color:#66d9ef">null</span>) <span style="color:#75715e">// does not exist at all</span> 492</span></span><span style="display:flex;"><span> { 493</span></span><span style="display:flex;"><span> _logger.Debug( 494</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">new</span> StringBuilder() 495</span></span><span style="display:flex;"><span> .Append(<span style="color:#e6db74">"There is no existing anonymous download for "</span>) 496</span></span><span style="display:flex;"><span> .Append(<span style="color:#e6db74">$"[{applicationSlug}], will create one"</span>) 497</span></span><span style="display:flex;"><span> .ToString() 498</span></span><span style="display:flex;"><span> ); 499</span></span><span style="display:flex;"><span> } 500</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">else</span> <span style="color:#75715e">// exists but expired</span> 501</span></span><span style="display:flex;"><span> { 502</span></span><span style="display:flex;"><span> _logger.Debug( 503</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">new</span> StringBuilder() 504</span></span><span style="display:flex;"><span> .Append(<span style="color:#e6db74">$"There is an existing anonymous download for "</span>) 505</span></span><span style="display:flex;"><span> .Append(<span style="color:#e6db74">$"[{applicationSlug}] - {applicationDownloadAnonymous.GUID} "</span>) 506</span></span><span style="display:flex;"><span> .Append(<span style="color:#e6db74">"- but it has already expired, will create a new one"</span>) 507</span></span><span style="display:flex;"><span> .ToString() 508</span></span><span style="display:flex;"><span> ); 509</span></span><span style="display:flex;"><span> } 510</span></span><span style="display:flex;"><span> <span style="color:#75715e">// add a new download to the database</span> 511</span></span><span style="display:flex;"><span> _databaseContext.AddApplicationVersionDownloadAnonymous( 512</span></span><span style="display:flex;"><span> applicationSlug, 513</span></span><span style="display:flex;"><span> applicationVersion, 514</span></span><span style="display:flex;"><span> downloadGUID, 515</span></span><span style="display:flex;"><span> <span style="color:#e6db74">$"{applicationSlug}.ipa"</span> <span style="color:#75715e">// iOS downloads might not be the only downloads of such nature, so there might need to be some additional logic here</span> 516</span></span><span style="display:flex;"><span> ); 517</span></span><span style="display:flex;"><span> } 518</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">else</span> 519</span></span><span style="display:flex;"><span> { 520</span></span><span style="display:flex;"><span> _logger.Debug( 521</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">new</span> StringBuilder() 522</span></span><span style="display:flex;"><span> .Append(<span style="color:#e6db74">$"Found an existing anonymous download for [{applicationSlug}] "</span>) 523</span></span><span style="display:flex;"><span> .Append(<span style="color:#e6db74">$"that has not expired yet: {applicationDownloadAnonymous.GUID}"</span>) 524</span></span><span style="display:flex;"><span> .ToString() 525</span></span><span style="display:flex;"><span> ); 526</span></span><span style="display:flex;"><span> downloadGUID = applicationDownloadAnonymous.GUID; 527</span></span><span style="display:flex;"><span> } 528</span></span><span style="display:flex;"><span> 529</span></span><span style="display:flex;"><span> <span style="color:#75715e">// redirect the request to itms-services:// URI of the actual manifest.plist</span> 530</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">return</span> Redirect( 531</span></span><span style="display:flex;"><span>
531 <span style="color:#66d9ef">new</span> StringBuilder() 532</span></span><span style="display:flex;"><span> .Append(<span style="color:#e6db74">"itms-services://?action=download-manifest&url="</span>) 533</span></span><span style="display:flex;"><span> .Append(<span style="color:#e6db74">"https://your.company.com"</span>) 534</span></span><span style="display:flex;"><span> .Append(<span style="color:#e6db74">$"/downloads/applications/{applicationSlug}/v{applicationVersion}"</span>) 535</span></span><span style="display:flex;"><span> .Append(<span style="color:#e6db74">$"/ipa/{downloadGUID}/manifest.plist"</span>) 536</span></span><span style="display:flex;"><span> .ToString() 537</span></span><span style="display:flex;"><span> ); 538</span></span><span style="display:flex;"><span>} 539</span></span></code></pre></div><p>The function for checking the download URL expiration:</p> 540<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#66d9ef">private</span> <span style="color:#66d9ef">bool</span> DownloadLinkHasExpired(DateTime dtCreated) 541</span></span><span style="display:flex;"><span>{ 542</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">return</span> (DateTime.Now - dtCreated).TotalHours > _configuration.GetSection( 543</span></span><span style="display:flex;"><span> <span style="color:#75715e">// instead of getting it from appsettings.json, you can just as well simply hardcode whatever value you want here</span> 544</span></span><span style="display:flex;"><span> <span style="color:#75715e">// (we've set 2 hours)</span> 545</span></span><span style="display:flex;"><span> <span style="color:#e6db74">"ApplicationsGallery:DownloadLinkExpirationHours"</span> 546</span></span><span style="display:flex;"><span> ).Get<<span style="color:#66d9ef">int</span>>(); 547</span></span><span style="display:flex;"><span>} 548</span></span></code></pre></div><h2 id="downloading-the-manifestplist">Downloading the manifest.plist</h2> 549<p>The route/action for getting the <code>manifest.plist</code>:</p> 550<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#75715e">// as you saw in the web server logs, this route should not have authentication,</span> 551</span></span><span style="display:flex;"><span><span style="color:#75715e">// otherwise iOS device services will fail to query stuff</span> 552</span></span><span style="display:flex;"><span><span style="color:#a6e22e">[AllowAnonymous]</span> 553</span></span><span style="display:flex;"><span><span style="color:#a6e22e">[Route("/downloads/applications/{applicationSlug}/v{applicationVersion}/ipa/{downloadGUID}/manifest.plist")]</span> 554</span></span><span style="display:flex;"><span><span style="color:#66d9ef">public</span> IActionResult ApplicationDownloadIpaManifest( 555</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">string</span> applicationSlug, 556</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">string</span> applicationVersion, 557</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">string</span> downloadGUID 558</span></span><span style="display:flex;"><span>) 559</span></span><span style="display:flex;"><span>{ 560</span></span><span style="display:flex;"><span> _logger.Debug( 561</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">new</span> StringBuilder() 562</span></span><span style="display:flex;"><span> .Append(<span style="color:#e6db74">$"Got a [{HttpContext.Request.Method}] request to download a manifest "</span>) 563</span></span><span style="display:flex;"><span> .Append(<span style="color:#e6db74">$"for the application [{applicationSlug}], version [{applicationVersion}], "</span>) 564</span></span><span style="display:flex;"><span> .Append(<span style="color:#e6db74">$"download GUID [{downloadGUID}]"</span>) 565</span></span><span style="display:flex;"><span> .ToString() 566</span></span><span style="display:flex;"><span> ); 567</span></span><span style="display:flex;"><span> 568</span></span><span style="display:flex;"><span> ApplicationDownloadAnonymous applicationDownloadAnonymous = <span style="color:#66d9ef">null</span>; 569</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">try</span> 570</span></span><span style="display:flex;"><span> { 571</span></span><span style="display:flex;"><span>
571 <span style="color:#75715e">// as we don't trust anyone, check for the existence of the requested download</span> 572</span></span><span style="display:flex;"><span> <span style="color:#75715e">// in the database (and get its data/info)</span> 573</span></span><span style="display:flex;"><span> applicationDownloadAnonymous = _databaseContext.GetApplicationVersionDownloadAnonymous( 574</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">new</span> Guid(downloadGUID) 575</span></span><span style="display:flex;"><span> ); 576</span></span><span style="display:flex;"><span> } 577</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">catch</span> (Exception ex) 578</span></span><span style="display:flex;"><span> { 579</span></span><span style="display:flex;"><span> _logger.Error( 580</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">new</span> StringBuilder() 581</span></span><span style="display:flex;"><span> .Append(<span style="color:#e6db74">$"Error on trying to get the download for GUID [{downloadGUID}]. "</span>) 582</span></span><span style="display:flex;"><span> .Append(ex.Message) 583</span></span><span style="display:flex;"><span> .ToString() 584</span></span><span style="display:flex;"><span> ); 585</span></span><span style="display:flex;"><span> 586</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">return</span> NotFound(); 587</span></span><span style="display:flex;"><span> } 588</span></span><span style="display:flex;"><span> 589</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">if</span> (applicationDownloadAnonymous == <span style="color:#66d9ef">null</span>) 590</span></span><span style="display:flex;"><span> { 591</span></span><span style="display:flex;"><span> _logger.Warn(<span style="color:#e6db74">$"There is no download with GUID [{downloadGUID}]"</span>); 592</span></span><span style="display:flex;"><span> 593</span></span><span style="display:flex;"><span> <span style="color:#75715e">// and maybe even do something more here (send an alert to admins, at least), for example</span> 594</span></span><span style="display:flex;"><span> <span style="color:#75715e">// if there are way too many "failed" requests, as it might indicate that someone is trying</span> 595</span></span><span style="display:flex;"><span> <span style="color:#75715e">// to guess/bruteforce unauthenticated download URLs</span> 596</span></span><span style="display:flex;"><span> 597</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">return</span> NotFound(); 598</span></span><span style="display:flex;"><span> } 599</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">else</span> <span style="color:#66d9ef">if</span> (DownloadLinkHasExpired(applicationDownloadAnonymous.Created)) 600</span></span><span style="display:flex;"><span> { 601</span></span><span style="display:flex;"><span> _logger.Warn(<span style="color:#e6db74">$"The download link for GUID [{downloadGUID}] has expired"</span>); 602</span></span><span style="display:flex;"><span> <span style="color:#75715e">// but don't let user know about this</span> 603</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">return</span> NotFound(); 604</span></span><span style="display:flex;"><span> } 605</span></span><span style="display:flex;"><span> 606</span></span><span style="display:flex;"><span> <span style="color:#75715e">// this is where the manifest template string gets substituted with the actual values</span> 607</span></span><span style="display:flex;"><span> <span style="color:#75715e">// to generate the resulting manifest.plist</span> 608</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">var</span> manifest = <span style="color:#66d9ef">string</span>.Format(_manifestPlist, 609</span></span><span style="display:flex;"><span> applicationSlug, 610</span></span><span style="display:flex;"><span> applicationVersion, 611</span></span><span style="display:flex;"><span> downloadGUID, 612</span></span><span style="display:flex;"><span> applicationDownloadAnonymous.AppleManifestBundleIdentifier, 613</span></span><span style="display:flex;"><span> applicationDownloadAnonymous.AppleManifestTitle 614</span></span><span style="display:flex;"><span> ); 615</span></span><span style="display:flex;"><span> 616</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">new</span> ContentResult 617</span></span><span style="display:flex;"><span> { 618</span></span><span style="display:flex;"><span> Content = manifest.ToString(), 619</span></span><span style="display:flex;"><span> ContentType = <span style="color:#e6db74">"text/xml"</span>, 620</span></span><span style="display:flex;"><span> StatusCode = <span style="color:#ae81ff">200</span> 621</span></span><span style="display:flex;"><span> }; 622</span></span><span style="display:flex;"><span>} 623</span></span></code></pre></div><h2 id="downloading-the-application">Downloading the application</h2> 624<p>Finally, the route/action for downloading the <code>*.ipa</code> file:</p> 625<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#75715e">// as you saw in the web server logs, this route should not have authentication either</span> 626</span></span><span style="display:flex;"><span><span style="color:#a6e22e">[AllowAnonymous]</span> 627</span></span><span style="display:flex;"><span><span style="color:#a6e22e">[Route("/downloads/applications/{applicationSlug}/v{applicationVersion}
627/ipa/{downloadGUID}")]</span> 628</span></span><span style="display:flex;"><span><span style="color:#66d9ef">public</span> IActionResult ApplicationDownloadIpa( 629</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">string</span> applicationSlug, 630</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">string</span> applicationVersion, 631</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">string</span> downloadGUID 632</span></span><span style="display:flex;"><span>) 633</span></span><span style="display:flex;"><span>{ 634</span></span><span style="display:flex;"><span> _logger.Debug( 635</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">new</span> StringBuilder() 636</span></span><span style="display:flex;"><span> .Append(<span style="color:#e6db74">$"Got a [{HttpContext.Request.Method}] request to download the IPA file "</span>) 637</span></span><span style="display:flex;"><span> .Append(<span style="color:#e6db74">$"for the application [{applicationSlug}], version [{applicationVersion}], "</span>) 638</span></span><span style="display:flex;"><span> .Append(<span style="color:#e6db74">$"download GUID [{downloadGUID}]"</span>) 639</span></span><span style="display:flex;"><span> .ToString() 640</span></span><span style="display:flex;"><span> ); 641</span></span><span style="display:flex;"><span> 642</span></span><span style="display:flex;"><span> ApplicationDownloadAnonymous applicationDownloadAnonymous = <span style="color:#66d9ef">null</span>; 643</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">try</span> 644</span></span><span style="display:flex;"><span> { 645</span></span><span style="display:flex;"><span> <span style="color:#75715e">// check in the database if this download even exists (and get its data/info)</span> 646</span></span><span style="display:flex;"><span> applicationDownloadAnonymous = _databaseContext.GetApplicationVersionDownloadAnonymous( 647</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">new</span> Guid(downloadGUID) 648</span></span><span style="display:flex;"><span> ); 649</span></span><span style="display:flex;"><span> } 650</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">catch</span> (Exception ex) 651</span></span><span style="display:flex;"><span> { 652</span></span><span style="display:flex;"><span> _logger.Error( 653</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">new</span> StringBuilder() 654</span></span><span style="display:flex;"><span> .Append(<span style="color:#e6db74">$"Error on trying to get the download for GUID [{downloadGUID}]. "</span>) 655</span></span><span style="display:flex;"><span> .Append(ex.Message) 656</span></span><span style="display:flex;"><span> .ToString() 657</span></span><span style="display:flex;"><span> ); 658</span></span><span style="display:flex;"><span> 659</span></span><span style="display:flex;"><span> <span style="color:#75715e">// if (_hostingEnvironment.IsDevelopment())</span> 660</span></span><span style="display:flex;"><span> <span style="color:#75715e">// {</span> 661</span></span><span style="display:flex;"><span> <span style="color:#75715e">// return new ContentResult</span> 662</span></span><span style="display:flex;"><span> <span style="color:#75715e">// {</span> 663</span></span><span style="display:flex;"><span> <span style="color:#75715e">// Content = "Failed to query the database.",</span> 664</span></span><span style="display:flex;"><span> <span style="color:#75715e">// ContentType = "text/plain",</span> 665</span></span><span style="display:flex;"><span> <span style="color:#75715e">// StatusCode = 500</span> 666</span></span><span style="display:flex;"><span> <span style="color:#75715e">// };</span> 667</span></span><span style="display:flex;"><span>
667 <span style="color:#75715e">// }</span> 668</span></span><span style="display:flex;"><span> 669</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">return</span> NotFound(); 670</span></span><span style="display:flex;"><span> } 671</span></span><span style="display:flex;"><span> 672</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">if</span> (applicationDownloadAnonymous == <span style="color:#66d9ef">null</span>) 673</span></span><span style="display:flex;"><span> { 674</span></span><span style="display:flex;"><span> _logger.Warn(<span style="color:#e6db74">$"There is no download with GUID [{downloadGUID}]"</span>); 675</span></span><span style="display:flex;"><span> 676</span></span><span style="display:flex;"><span> <span style="color:#75715e">// yet again, maybe do something more here (send an alert to admins, at least), for example</span> 677</span></span><span style="display:flex;"><span> <span style="color:#75715e">// if there are way too many "failed" requests, as it might indicate that someone is trying</span> 678</span></span><span style="display:flex;"><span> <span style="color:#75715e">// to guess/bruteforce unauthenticated download URLs</span> 679</span></span><span style="display:flex;"><span> 680</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">return</span> NotFound(); 681</span></span><span style="display:flex;"><span> } 682</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">else</span> <span style="color:#66d9ef">if</span> (DownloadLinkHasExpired(applicationDownloadAnonymous.Created)) 683</span></span><span style="display:flex;"><span> { 684</span></span><span style="display:flex;"><span> _logger.Warn(<span style="color:#e6db74">$"The download link for GUID [{downloadGUID}] has expired"</span>); 685</span></span><span style="display:flex;"><span> <span style="color:#75715e">// still don't let user know about this</span> 686</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">return</span> NotFound(); 687</span></span><span style="display:flex;"><span> } 688</span></span><span style="display:flex;"><span> 689</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">var</span> downloadPath = <span style="color:#e6db74">$"{_hostingEnvironment.WebRootPath}/files/applications/{applicationSlug}/v{applicationVersion}/{applicationDownloadAnonymous.FileName}"</span>; 690</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">if</span> (!System.IO.File.Exists(downloadPath)) 691</span></span><span style="display:flex;"><span> { 692</span></span><span style="display:flex;"><span> _logger.Warn(<span style="color:#e6db74">$"Could not find the file [{downloadPath}]"</span>); 693</span></span><span style="display:flex;"><span> <span style="color:#75715e">// user should probabably not know about this either</span> 694</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">return</span> NotFound(); 695</span></span><span style="display:flex;"><span> } 696</span></span><span style="display:flex;"><span> 697</span></span><span style="display:flex;"><span> <span style="color:#66d9ef">return</span> PhysicalFile( 698</span></span><span style="display:flex;"><span> downloadPath, 699</span></span><span style="display:flex;"><span> <span style="color:#e6db74">"application/octet-stream"</span>, 700</span></span><span style="display:flex;"><span> applicationDownloadAnonymous.FileName 701</span></span><span style="display:flex;"><span> ); 702</span></span><span style="display:flex;"><span>} 703</span></span></code></pre></div><p>It should be obvious and already known by now, but probably won’t hurt to explicitly state this one more time anyway: direct static URLs to the “physical” <code>/files/applications/{applicationSlug}/v{applicationVersion}/some-application.ipa</code> files should never be exposed in your website routes without authentication/authorization. That is the entire point of having those temporary GUID-based URLs in the unprotected/anonymous <code>ApplicationDownloadIpa()</code> route/action, which is meant to be serving the actual files via <a href="https://learn.microsoft.com/en-us
703/dotnet/api/microsoft.aspnetcore.mvc.controllerbase.physicalfile#microsoft-aspnetcore-mvc-controllerbase-physicalfile(system-string-system-string-system-string)" rel="external">PhysicalFile</a>.</p> 704<p>Now, if it’s all good, your users/employees will be able to download and install iOS applications from your company website:</p> 705<p> 706 707 708 <img class="image-post" loading="lazy" src="/blog/2025/04/03/distributing-ios-applications-without-app-store/images/installing-ios-application-from-website.png" alt="Installing an iOS application from a website"> 709 710</p> 711<p>If anything, the screenshot above shows an admin view with additional controls. Regular users will see a simplified “less capable” variant.</p> 712 </div> 713 </article><div id="tags"> 714 <i class="bi-hash"></i> 715 <a class="tag" href="https://decovar.dev/tags/apple/">apple</a><a class="tag" href="https://decovar.dev/tags/ios/">ios</a><a class="tag" href="https://decovar.dev/tags/web/">web</a><a class="tag" href="https://decovar.dev/tags/dotnet/">dotnet</a></div><hr class="comments-divider"> 716<div id="comments"> 717 <div id="remark42"></div> 718</div> 719 720 721 </div> 722 <footer id="footer"> 723 <div> 724 2014 - 2026, 725 <a href="/about/">Declaration of VAR</a> 726 </div> 727 <div><a href="/about/#licenses">CC BY-SA 4.0 / GPLv3</a></div> 728 </footer> 729 </div><div id="sidebar"> 730 <div> 731 <input id="search-query" placeholder="Search in blog titles and summaries" /> 732 <div id="search-results" style="display:none;"></div> 733 </div> 734 <div id="sidebars" style="margin-top:20px;"> 735 <a id="avatar" href="/about"> 736 <img class="app-header-avatar" src="/images/avas/spike.png" /> 737 </a> 738 <div class="sidebar-section" style="margin-top:1em;"> 739 <h3 style="margin-top:0;">Donation</h3> 740 <p style="margin:8px 0 0 0;"> 741 You can support me by donating cryptocurrency to either of the following wallets 742 (<i><a href="/donation.txt" class="link-decorated">signed variant</a></i>): 743 </p> 744 <ul class="cryptocurrency-wallets"> 745 <li class="cryptocurrency-wallet-information"> 746 <p>XLM</p> 747 <pre><code>GDYB2QGJYWT6VPT7O7INWIGRGJF5ZEAQ7Y73S4T47IOP2QQ554NPN3ZM</code></pre> 748 </li> 749 <li class="cryptocurrency-wallet-information"> 750 <p>BTC</p> 751 <pre><code>bc1q2kfqwm2kwyw37ay2g4tnguasvp6j4hsmtrgjte</code></pre> 752 </li> 753 </ul> 754 </div> 755 <div class="sidebar-section"> 756 <h3>Feeds</h3> 757 <div class="sidebar-row"> 758 <a href="/index.xml" title="RSS feed for blog posts"> 759 <img src="/images/rss.png" class="sidebar-row-icon"> 760 </a> 761 762 <a href="https://t.me/decovar" title="Telegram channel"> 763 <img src="/images/telegram.png" class="sidebar-row-icon"> 764 </a> 765 <a href="/etc/comments/" title="Latest comments"> 766 <img src="/images/comments.png" class="sidebar-row-icon" 767 aria-description="Original image: https://flaticon.com/free-icon/chat_4021713"> 768 </a> 769 </div> 770 </div> 771 772 773 774 <div class="sidebar-section"> 775 <h3>Tags</h3> 776 <ul class="tags-list tags-list-horizontal"> 777 778 <li><a href="/tags/web">#web (70)</a></li> 779 780 <li><a href="/tags/fail">#fail (40)</a></li> 781 782 <li><a href="/tags/dotnet">#dotnet (38)</a></li> 783 784 <li><a href="/tags/qt">#qt (35)</a></li> 785 786 <li><a href="/tags/linux">#linux (28)</a></li> 787 788 <li><a href="/tags/review">#review (28)</a></li> 789 790 <li><a href="/tags/devops">#devops (23)</a></li> 791 792 <li><a href="/tags/macos">#macos (23)</a></li> 793 794 <li><a href="/tags/windows">#windows (19)</a></li> 795 796 <li><a href="/tags/mestuff">#mestuff (17)</a></li> 797 798 <li><a href="/tags/movies">#movies (15)</a></li> 799 800 <li><a href="/tags/norway">
800#norway (15)</a></li> 801 802 <li><a href="/tags/tractor">#tractor (15)</a></li> 803 804 <li><a href="/tags/cpp">#cpp (13)</a></li> 805 806 <li><a href="/tags/cmake">#cmake (12)</a></li> 807 808 <li><a href="/tags/irl">#irl (12)</a></li> 809 810 <li><a href="/tags/python">#python (12)</a></li> 811 812 <li><a href="/tags/telegram">#telegram (12)</a></li> 813 814 <li><a href="/tags/embedded">#embedded (11)</a></li> 815 816 <li><a href="/tags/soft">#soft (10)</a></li> 817 818 <li><a href="/tags/travel">#travel (10)</a></li> 819 820 <li><a href="/tags/applescript">#applescript (9)</a></li> 821 822 <li><a href="/tags/piracy">#piracy (9)</a></li> 823 824 <li><a href="/tags/ios">#ios (8)</a></li> 825 826 <li><a href="/tags/octopress">#octopress (8)</a></li> 827 828 <li><a href="/tags/apple">#apple (7)</a></li> 829 830 <li><a href="/tags/banks">#banks (7)</a></li> 831 832 <li><a href="/tags/games">#games (7)</a></li> 833 834 <li><a href="/tags/javascript">#javascript (6)</a></li> 835 836 <li><a href="/tags/photo">#photo (6)</a></li> 837 838 <li><a href="/tags/sql">#sql (6)</a></li> 839 840 <li><a href="/tags/russia">#russia (5)</a></li> 841 842 <li><a href="/tags/android">#android (4)</a></li> 843 844 <li><a href="/tags/hugo">#hugo (4)</a></li> 845 846 <li><a href="/tags/usa">#usa (4)</a></li> 847 848 <li><a href="/tags/wordpress">#wordpress (4)</a></li> 849 850 <li><a href="/tags/azure">#azure (3)</a></li> 851 852 <li><a href="/tags/tv">#tv (3)</a></li> 853 854 <li><a href="/tags/home-automation">#home-automation (2)</a></li> 855 856 <li><a href="/tags/privacy">#privacy (2)</a></li> 857 858 <li><a href="/tags/steam-deck">#steam-deck (2)</a></li> 859 860 <li><a href="/tags/tvos">#tvos (2)</a></li> 861 862 <li><a href="/tags/unsolved">#unsolved (2)</a></li> 863 864 <li><a href="/tags/books">#books (1)</a></li> 865 866 <li><a href="/tags/ffmpeg">#ffmpeg (1)</a></li> 867 868 <li><a href="/tags/ldap">#ldap (1)</a></li> 869 870 <li><a href="/tags/music">#music (1)</a></li> 871 872 <li><a href="/tags/science">#science (1)</a></li> 873 874 <li><a href="/tags/vr">#vr (1)</a></li> 875 876 </ul> 877 </div> 878 </div> 879</div> 880</main> 881 882
882<script> 883 884 const navigationMobile = document.getElementById("navigation-mobile"); 885 const hamburgerOpenClassName = "is-active"; 886 887 888 window.onload = () => 889 { 890 891 892 893 894 895 896 897 898 899 900 901 } 902 903 function toggleHamburger(hamburger) 904 { 905 if (hamburger.classList.contains(hamburgerOpenClassName)) 906 { 907 908 hamburger.classList.remove(hamburgerOpenClassName); 909 910 navigationMobile.style.display = "none"; 911 } 912 else 913 { 914 hamburger.classList.add(hamburgerOpenClassName); 915 916 navigationMobile.style.display = "flex"; 917 } 918 } 919 </script>
919 920 921
921<script src="/js/mark.js"></script>
921 922
922<script src="/js/search.js"></script>
922 923 924
924<script> 925 var remark_config = { 926 host: "https:\/\/comments.decovar.dev", 927 site_id: "decovar.dev", 928 components: ["embed"], 929 url: "https:\/\/decovar.dev\/blog\/2025\/04\/03\/distributing-ios-applications-without-app-store\/", 930 max_shown_comments: 25, 931 theme: "light", 932 page_title: "Distributing iOS applications within a company from a website without App Store", 933 locale: "en", 934 simple_view: false 935 }; 936 </script>
936 937
938<script>!function(e,n){for(var o=0;o<e.length;o++){var r=n.createElement("script"),c=".js",d=n.head||n.body;"noModule"in r?(r.type="module",c=".mjs"):r.async=!0,r.defer=!0,r.src=remark_config.host+"/web/"+e[o]+c,d.appendChild(r)}}(remark_config.components||["embed"],document);</script>
938 939 940 941 942 </body> 943</html>
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.