Entra sign-in certificate
The certificate apps/web signs in to Entra with, one per environment, and how to replace it.
apps/web authenticates to Microsoft Entra ID with a certificate, not a client
secret. It signs a private-key JWT with ENTRA_CLIENT_PRIVATE_KEY and names the
certificate in the JWT’s x5t header with ENTRA_CLIENT_CERT_THUMBPRINT. Entra
accepts the sign-in only when that thumbprint belongs to a certificate uploaded
to the app registration and the key is that certificate’s private key.
One certificate per environment
Each Heizen Studio environment carries its own pair:
| Studio environment | Used by | Certificate subject |
|---|---|---|
dev-aws |
the AWS dev box | CN=flowos-dev-aws |
development |
pnpm dev on a laptop |
CN=flowos-local-dev |
The app registration holds all of them at once. A key copied off a laptop signs
in only as local development, and its certificate is removed without touching
dev-aws.
Both values come from Studio and nowhere else. A line in apps/web/.env.local,
or in /opt/flowos/.env on the box, shadows the Studio value, and a key from
the file paired with a thumbprint from Studio fails every sign-in. Neither file
holds ENTRA_CLIENT_PRIVATE_KEY.
Replacing a certificate
Replace a certificate before it expires, and at once for every environment whose key has been exposed. Keys stay outside the repository and never reach a terminal’s output.
-
Generate the key and certificate in a private folder outside the repository:
D=~/.flowos-secrets/entra-<env>-<date> mkdir -p "$D" && chmod 700 "$D" && cd "$D" openssl req -x509 -newkey rsa:2048 -nodes -sha256 -days 365 \ -subj "/CN=flowos-<env>" -keyout key.pem -out cert.pem chmod 600 key.pemThe certificate is valid for a year, and Entra refuses it after that.
-
Upload the certificate to the FlowOS app registration, beside the current one:
az login --tenant <tenant-id> --allow-no-subscriptions az ad app credential reset --id <client-id> --append --cert "@$D/cert.pem"--appendis required. Without it,credential resetreplaces every credential on the app, including the certificates the other environments sign in with.<tenant-id>and<client-id>areENTRA_TENANT_IDandENTRA_CLIENT_IDin Studio; sign in with an account in the client’s tenant that can manage the app registration. Uploadingcert.pemunder Certificates & secrets → Certificates in the Azure portal does the same. -
Set both values in Studio, in that environment, in one sitting — a thumbprint and a key from different pairs fail every sign-in.
-
ENTRA_CLIENT_CERT_THUMBPRINTis the certificate’s SHA-1 thumbprint, base64url-encoded:openssl x509 -in cert.pem -outform DER | openssl dgst -sha1 -binary \ | openssl base64 | tr '+/' '-_' | tr -d '=' -
ENTRA_CLIENT_PRIVATE_KEYis the key, copied without printing it. Studio keeps a multi-line PEM intact; the key on one line, with its line breaks removed, works as well and survives a paste box that mangles line breaks:tr -d '\r\n' < key.pem | pbcopyA key with its line breaks escaped as
\nis rejected byjose.
-
-
Restart what reads them. Both values are read when the process starts.
-
dev-aws: recreatewebon the image it already runs, over SSM (the box has no SSH). The next deploy does the same.cd /opt/flowos IMAGE=$(docker inspect flowos-deploy-web-1 --format '{{.Config.Image}}') ECR_REGISTRY=${IMAGE%%/*} IMAGE_TAG=${IMAGE##*:} \ docker compose -f docker-compose.deploy.yml up -d --no-deps --force-recreate web -
Local development: restart
pnpm dev.
Then sign in on each.
-
-
Remove the old certificate once every environment signs in with its new one. The old key stops working at this step and not before.
az ad app credential list --id <client-id> --cert \ --query '[].{name:displayName, keyId:keyId, thumbprint:customKeyIdentifier, expires:endDateTime}' -o table az ad app credential delete --id <client-id> --cert --key-id <old-key-id>thumbprintthere is the hex form;openssl x509 -in cert.pem -noout -fingerprint -sha1prints the same for a local certificate. Then delete the local folder withtrash "$D".
Checking what Studio holds
Run a check through flowos-secrets from the repository root, not from
apps/web. Inside a package directory the check also reads that package’s
.env.local, so a stale key there is what it reports.