---
title: Entra sign-in certificate
description: The certificate apps/web signs in to Entra with, one per environment, and how to replace it.
sidebar:
  label: Entra certificate
  order: 8
---

`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.

1. **Generate the key and certificate** in a private folder outside the
   repository:

   ```sh
   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.pem
   ```

   The certificate is valid for a year, and Entra refuses it after that.

2. **Upload the certificate** to the FlowOS app registration, beside the
   current one:

   ```sh
   az login --tenant <tenant-id> --allow-no-subscriptions
   az ad app credential reset --id <client-id> --append --cert "@$D/cert.pem"
   ```

   `--append` is required. Without it, `credential reset` replaces every
   credential on the app, including the certificates the other environments
   sign in with. `<tenant-id>` and `<client-id>` are `ENTRA_TENANT_ID` and
   `ENTRA_CLIENT_ID` in Studio; sign in with an account in the client's tenant
   that can manage the app registration. Uploading `cert.pem` under
   **Certificates & secrets → Certificates** in the Azure portal does the same.

3. **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_THUMBPRINT` is the certificate's SHA-1 thumbprint,
     base64url-encoded:

     ```sh
     openssl x509 -in cert.pem -outform DER | openssl dgst -sha1 -binary \
       | openssl base64 | tr '+/' '-_' | tr -d '='
     ```

   - `ENTRA_CLIENT_PRIVATE_KEY` is 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:

     ```sh
     tr -d '\r\n' < key.pem | pbcopy
     ```

     A key with its line breaks escaped as `\n` is rejected by `jose`.

4. **Restart what reads them.** Both values are read when the process starts.

   - `dev-aws`: recreate `web` on the image it already runs, over SSM (the box
     has no SSH). The next deploy does the same.

     ```sh
     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.

5. **Remove the old certificate** once every environment signs in with its new
   one. The old key stops working at this step and not before.

   ```sh
   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>
   ```

   `thumbprint` there is the hex form;
   `openssl x509 -in cert.pem -noout -fingerprint -sha1` prints the same for a
   local certificate. Then delete the local folder with `trash "$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.
