OTA code signing

Only code you signed reaches your users

Sign every over-the-air update with a private key you hold. Your app embeds the matching certificate and refuses any update that is not signed by it. Included on every Ubriot plan.

What it protects against

With your own key, a stolen Ubriot password or API key, a compromised Ubriot server or database, a malicious insider, a tampered storage bucket or CDN, and network interception can no longer ship JavaScript to your installed apps. The private key never reaches Ubriot: the CLI signs the exact manifest bytes on your machine or in CI, Ubriot checks the signature at publish time and serves it unchanged.

It does not protect the build pipeline that produces your binaries, a laptop or CI secret that holds the private key, or, for apps using expo-updates, replaying an older update signed by the same key on the same runtime version. Ubriot can still withhold updates or roll a channel back to an earlier signed update; it cannot sign new code.

Quick start

Generate a key outside your repository, register its certificate, and point your app at it:

ubriot signing init --app my-app
ubriot signing configure --app my-app

Then bump runtimeVersion and ship a new native build. Publishing works as before and is signed automatically:

ubriot update --app my-app --channel production

Old binaries without a certificate keep receiving updates. When ubriot signing status shows they are gone, refuse unsigned publishes with ubriot signing require --app my-app.

Signing in CI

Store the private key PEM as a CI secret. The CLI matches it to the registered certificate:

UBRIOT_OTA_PRIVATE_KEY="$(cat ../ubriot-keys/my-app-main.private.pem)" \
  ubriot update --app my-app --channel production --yes

Simple mode and chain mode

Simple mode embeds one self-signed certificate, the same setup as EAS Update code signing. Replacing that key needs a new binary.

Chain mode embeds an offline root certificate. Short-lived signing certificates issued from it sign your updates, and Ubriot sends the chain with each update. Rotating a signing certificate needs no new binary:

ubriot signing init --app my-app --mode chain
ubriot signing issue-leaf --app my-app --root-key /secure/my-app-root.private.pem

issue-leaf registers the new certificate, re-signs the updates your channels point at, and retires the old one. Keep the root key offline. Expo apps in chain mode also get the ubriot-updates-rn/code-signing config plugin, added for you by signing configure.

Expo and bare React Native

Expo apps verify with stock expo-updates, configured through expo.updates.codeSigningCertificate and codeSigningMetadata. Never set codeSigningAllowUnsignedManifests.

Bare apps on ubriot-updates-rn 0.3 or later verify natively on iOS and Android, also bind each update to your app, platform and runtime, refuse an update older than the newest one the device accepted unless it ran that update before (to roll back devices that never had the older update, publish the old code again), and can trust a pre-provisioned backup key so simple mode can rotate without a binary (ubriot signing configure --also-trust next).

Rotation and revocation

Simple mode: create the next key with ubriot signing rotate --app my-app --keyid main-2, ship a build that trusts it, publish with both keys (--sign-with main --sign-with main-2) while old binaries are in use, and retire the old key once its device count reaches zero.

If a key leaks, revoke it with ubriot signing revoke --app my-app --keyid main. Ubriot stops serving its signatures and refuses publishes signed by it. Binaries that trust only that key stay exposed to anyone holding it until they update, so ship a new build with a new certificate, or in chain mode issue a new leaf.

Moving from EAS Update with code signing

Register the certificate your current binaries embed so they keep updating from Ubriot:

ubriot signing import --app my-app --keyid main --certificate certs/certificate.pem --private-key-path keys/private-key.pem

Ubriot-managed keys (paid plans)

Teams that want signing from the dashboard, MCP tools or CI without handling keys can let Ubriot generate and hold the key. Keys are encrypted with a per-key data key under a master key, every signature is written to an audit log, and rotation in chain mode needs no new binary.

Ubriot-managed keys protect against tampering in storage and transit. They do not protect against a compromised Ubriot account or a compromised Ubriot, because Ubriot can sign. Use your own keys for that guarantee.

ubriot signing managed create --app my-app
ubriot signing configure --app my-app --certificate ./certs/ubriot-managed-root.pem --mode chain
ubriot signing managed audit --app my-app

Turning signing on never bricks an app

A failed signature check never removes running code: the app keeps its current bundle and simply does not install the update. Old binaries without a certificate keep working. Check what a binary trusts with ubriot signing verify-binary app.ipa.

Troubleshooting rejected updates

Rejections appear in the Updates dashboard and in ubriot signing status:

  • missing-signature: The device trusts a key that has no signature on the current update. Re-sign it with ubriot updates resign, or publish again with that key.
  • bad-signature: The update bytes do not match the signature. Something changed the manifest after signing; publish again from a clean export.
  • untrusted-certificate: The signing certificate is not the one built into the binary (wrong keyid, wrong root, or a key that is not a code signing certificate).
  • expired-certificate: The certificate has expired, or the device clock is far off. Rotate before expiry; ubriot signing status warns at 60 and 14 days.
  • binding-mismatch: ubriot-updates-rn only: the signed update names a different app or runtime version, or is older than one the device already installed.
  • native-unsupported: The binary asks for signed updates but its native module is too old to verify them. Ship a new build with ubriot-updates-rn 0.3 or later.

Questions? Read how Ubriot handles security.