Originally published at https://bestlatestapps.com/blog/reading-a-mobileprovision-file-in-the-browser/.
How my provisioning profile viewer pulls the XML out of a signed wrapper, reads X.509 certificates with a tiny DER reader, infers the profile type and explains the failures it finds
Every iOS developer has had this afternoon. A build installs on your phone and not on your tester's. Push works in debug and fails in TestFlight. An extension can't reach the App Group. Nine times out of ten, the answer is sitting in a provisioning profile that you can't easily read.
On a Mac, the classic move is security cms -D -i profile.mobileprovision and a scroll through a wall of XML. That works if you're on a Mac, you remember the flags, and you know what to look for. I wanted something quicker: drop the file on a web page and get the answers that matter (type, expiry, devices, certificates, entitlements), with warnings in plain English.
So I built the .mobileprovision viewer on my site. This article walks through how it works, using the tool's real source. The snippets are taken from, or trimmed down from, mobileprovision.js. Where I generalise beyond what the code does, I say so.
1. What is actually inside a provisioning profile
A .mobileprovision (or .provisionprofile on the Mac) is a property list wrapped in a CMS signature. CMS is the Cryptographic Message Syntax, the standard that grew out of PKCS \#7. Apple signs the plist, and the signed bundle is stored in DER, the binary encoding used for certificates.
Two facts shape the whole design:
- The format isn't documented as an API. Apple's technote TN3125 describes profiles but is clear that the file's layout can change, and that modern systems rely on a binary
DER-Encoded-Profileentry inside the plist as the source of truth. A viewer should read the human-readable plist and not pretend to be the system's validator. - CMS signed data with embedded content stores the content as-is. The XML plist sits inside the DER bytes unencrypted. You don't need a CMS parser to find it; you need to find the bytes between
<?xmland</plist>.
That second fact is the shortcut the whole tool is built on. It also sets the limit I'm honest about on the page: the viewer reads a profile, it doesn't prove that Apple signed it. Every result says "Signature not verified".
2. Finding the plist without a CMS parser
The file arrives as an ArrayBuffer from FileReader. The first step converts the bytes to a "binary string", one character per byte, so ordinary string searches work:
`` javascript
function bytesToLatin1(u8) {
var s = "", CH = 0x8000;
for (var i = 0; i < u8.length; i += CH)
s += String.fromCharCode.apply(null, u8.subarray(i, i + CH));
return s;
}
CODEBLOCK0 javascript
function extractPlist(u8) {
var s = bytesToLatin1(u8), a = s.indexOf("<?xml"), b = s.indexOf("</plist>", a);
if (a < 0) a = s.indexOf("<plist");
if (a < 0 || b < 0) {
if (s.slice(0, 6) === "bplist")
throw new Error("This is a binary plist, not a provisioning profile. Convert it with: plutil -convert xml1 file");
throw new Error("No property list found. Is this a .mobileprovision or .provisionprofile file?");
}
return utf8(u8.subarray(a, b + 8));
}
CODEBLOCK1 javascript
var api = { extractPlist: extractPlist, parsePlist: parsePlist, analyze: analyze, cert: cert, profileType: profileType, fmtVal: fmtVal };
if (typeof module === "object" && module.exports) module.exports = api;
root.MobileProvision = api;
if (typeof document === "undefined") return;
CODEBLOCK2 javascript
var re = /<(\/?)([a-zA-Z]+)(?:\s+[a-zA-Z][^>]*?)?(\s*\/)?>|<!--[\s\S]*?-->|<\?[\s\S]*?\?>|<!DOCTYPE[^>]*>/g;
while ((m = re.exec(xml))) {
if (m[2]) toks.push({ close: !!m[1], tag: m[2], self: !!m[3], start: m.index, end: re.lastIndex,
textBefore: xml.slice(last, m.index) });
last = re.lastIndex;
}
CODEBLOCK3 javascript
switch (t.tag) {
case "dict":
var o = {};
while (toks[i] && !(toks[i].close && toks[i].tag === "dict")) {
var k = toks[i++]; if (k.tag !== "key") throw new Error("Expected <key> in <dict>");
var ke = expect("key"); o[decodeEntities(ke.textBefore)] = val();
}
expect("dict"); return o;
case "array": /* same idea, collecting val() until </array> */
case "true": expect("true"); return true;
case "false": expect("false"); return false;
default:
var e = expect(t.tag), text = decodeEntities(e.textBefore);
if (t.tag === "string") return text;
if (t.tag === "integer") return parseInt(text, 10);
if (t.tag === "real") return parseFloat(text);
if (t.tag === "date") return new Date(text.trim());
if (t.tag === "data") return b64(text);
return text;
}
CODEBLOCK4 javascript
function der(u8, p) {
var tag = u8[p], len = u8[p + 1], hl = 2;
if (len & 0x80) { var n = len & 0x7f; len = 0; for (var k = 0; k < n; k++) len = len * 256 + u8[p + 2 + k]; hl = 2 + n; }
return { tag: tag, start: p, hs: p + hl, end: p + hl + len };
}
function kids(u8, node) { var out = [], p = node.hs; while (p < node.end) { var c = der(u8, p); out.push(c); p = c.end; } return out; }
CODEBLOCK5 javascript
var c = der(u8, 0), tbs = kids(u8, c)[0], f = kids(u8, tbs), i = f[0].tag === 0xa0 ? 1 : 0;
// f[i] serial, f[i+1] signature algorithm, f[i+2] issuer, f[i+3] validity, f[i+4] subject
CODEBLOCK6 javascript
function profileType(p) {
var ent = p.Entitlements || {}, gta = ent["get-task-allow"] === true || ent["com.apple.security.get-task-allow"] === true;
var mac = (p.Platform || []).indexOf("OSX") >= 0;
if (p.ProvisionsAllDevices) return mac ? "Developer ID" : "Enterprise (In-House)";
if (p.ProvisionedDevices) return gta ? "Development" : "Ad Hoc";
return gta ? "Development (no device list)" : mac ? "Mac App Store" : "App Store";
}
CODEBLOCK7 javascript
if (exp && exp < n) warnings.push(["err", "This profile expired on " + exp.toISOString().slice(0, 10) + ". Regenerate it in Certificates, Identifiers & Profiles (or let Xcode manage signing)."]);
else if (days !== null && days < 30) warnings.push(["warn", "Expires in " + days + " day" + (days === 1 ? "" : "s") + "."]);
// ...
var aps = ent["aps-environment"] || ent["com.apple.developer.aps-environment"];
if (aps === "development") warnings.push(["note", "aps-environment is development: device tokens from this build only work with api.sandbox.push.apple.com."]);
if (aps === "production") warnings.push(["note", "aps-environment is production: tokens work with api.push.apple.com, not the sandbox."]);
CODEBLOCK8 javascript
/* the profile must not stay in the file input after it is read (input.files is readable by any script on the page) */
function resetFile() { var fi = $("[data-file]"); if (fi) fi.value = ""; }
// ...called before reading, in onload and onerror, and on pagehide
window.addEventListener("pagehide", resetFile);
`
The comment says why. Once the bytes are parsed, there's no reason for a File object to sit in a DOM element where any script on the page could read it.
**"Clear" means clear.** The Clear button doesn't just hide the result panel. It empties every table, the device list, the warnings and the raw plist view, and resets the search box, so nothing from the profile remains in the page.
**Everything is rendered with textContent.** The values come from a file the user dropped, which could have been crafted. Building rows with textContent rather than innerHTML means a "profile name" containing markup is shown as text, never run. It's a habit worth having in any tool that renders file contents.
------------------------------------------------------------------------
## 8. Using it day to day
The workflows I reach for most:
- **"Why won't it install on this phone?"** Open the build's profile and search the Devices list for the tester's UDID. App Store profiles have no device list at all, and Enterprise profiles cover all devices, so the count line says which case you're in.
- **"What was this build actually signed with?"** An .ipa is a zip. Inside, the .app bundle carries embedded.mobileprovision. Drop that file in and you see the profile the build shipped with, not the one you think Xcode used.
- **"Did my regenerated profile pick up the change?"** A profile is a snapshot. Adding a device or a capability doesn't change copies you already downloaded. Open the old and new files and compare the UUID, the device count and the entitlements.
- **"Is associated-domains in there?"** If com.apple.developer.associated-domains doesn't list your domain, no apple-app-site-association file will make universal links work. When it does list it, the [AASA validator](https://bestlatestapps.com/tools/aasa-validator/) checks the other half.
The tool page also notes where Xcode keeps downloaded profiles: ~/Library/Developer/Xcode/UserData/Provisioning Profiles on Xcode 16 and later, and ~/Library/MobileDevice/Provisioning Profiles before that. Each file is named by its UUID, which the viewer shows, so you can match a file on disk to what you're looking at.
------------------------------------------------------------------------
## 9. Before you ship a file-inspection tool: a checklist
1. **Parse in pure functions** that take bytes and return data, with the DOM wiring at the bottom, so you can test them in Node.
2. **Look for the cheap path first.** Here, "the content is embedded as-is" meant no CMS parser at all.
3. **Convert large byte arrays to strings in chunks.** apply has an argument limit.
4. **Fail with instructions.** "This is a binary plist; run plutil -convert xml1" beats "parse error".
5. **Label inferences as inferences**, like "Type (inferred)", and say clearly what you don't verify.
6. **Don't count failures as facts.** An unreadable certificate isn't an expired one.
7. **Make "now" a parameter** anywhere you compute days left.
8. **Render file contents with textContent`, clear file inputs after reading, and make Clear really clear.
Wrapping up
A provisioning profile looks like a cryptographic black box, but for viewing purposes it's an XML plist in a signed envelope. Find the XML by its markers, parse it with a small tokenizer, read the certificates with a 30-line DER walker, and spend the rest of your effort on what developers need: plain-English warnings about expiry, certificates and the push environment.
Try it, or browse the related tools:
- .mobileprovision viewer: https://bestlatestapps.com/tools/mobileprovision-viewer/
- AASA validator: https://bestlatestapps.com/tools/aasa-validator/
- Push notification tester: https://bestlatestapps.com/tools/push-notification-tester/
- All of my free web tools: https://bestlatestapps.com/tools/
- My portfolio:* https://bestlatestapps.com/alok/
The next time a build installs on one device and not another, open the profile before you open Xcode's settings.
— Alok Kumar Singh*