# Connect Microservices Safely, Part 2: Allowed Origins, Secret Rotation and Timeouts

To connect microservices safely on Light Cloud, keep every service address and secret in environment variables, allow each browser origin explicitly with CORS, rotate shared secrets in three saves (receiver accepts both, caller switches, receiver drops the old one), and put a timeout on every call between services. Each save redeploys only the service you changed, so none of this needs downtime.

This is Part 2 of the series. It continues from [Part 1](/tutorials/microservices-on-light-cloud-part-1-deploy), where you deployed Bean There: a React shop front, a Node.js catalog-api, a Python orders-api and PostgreSQL. Here you make the connections between them production-ready.

## What you will build

Three changes to the same running shop:

- **Several allowed origins.** The APIs answer both the live site and `http://localhost:5173`, so you can run the frontend on your laptop against the deployed APIs.
- **Secret rotation without downtime.** You replace the secret orders-api uses to call catalog-api while orders keep working.
- **A timeout between services.** If catalog-api does not answer within 5 seconds, orders-api says so clearly instead of hanging.

Live demo: [main-web-examples.light-cloud.io](https://main-web-examples.light-cloud.io). Source code: [github.com/light-cloud-com/tutorial-microservices](https://github.com/light-cloud-com/tutorial-microservices), tag `part-2`.

## Before you start

- Bean There deployed from Part 1: web, catalog-api, orders-api and bean-there-db, all running.
- Your fork of `tutorial-microservices`.
- A terminal: macOS and Linux have [curl](https://curl.se/) and [OpenSSL](https://www.openssl.org/) built in. On Windows, use [PowerShell 7](https://learn.microsoft.com/en-us/powershell/scripting/install/installing-powershell-on-windows) (`winget install Microsoft.PowerShell`); the Windows tabs use [`curl.exe`](https://curl.se/windows/), which ships with Windows 10 and 11.
- [Git](https://git-scm.com/downloads), to pull the Part 2 code into your fork.

## Step 1: Get the Part 2 code

The Part 2 changes live in `catalog-api` and `orders-api`. Pull them into your fork.

1. Open your fork on GitHub.
2. Click **Sync fork**, then **Update branch**.

Or from a clone of your fork. You should see the pull fast-forward to the Part 2 commit, touching only the two APIs and the README, and the push end with the same commit range (output trimmed):

```shell os="macOS,Linux"
$ git remote add upstream https://github.com/light-cloud-com/tutorial-microservices.git
$ git pull upstream main
Updating 8023f1a..ddcbae0
Fast-forward
 README.md             | 36 +++++++++++++++---------------------
 catalog-api/server.js | 35 ++++++++++++++++++++++++++++-------
 orders-api/main.py    | 25 +++++++++++++++++--------
 3 files changed, 60 insertions(+), 36 deletions(-)
$ git push origin main
   8023f1a..ddcbae0  main -> main
```

```shell os="Windows"
PS> git remote add upstream https://github.com/light-cloud-com/tutorial-microservices.git
PS> git pull upstream main
Updating 8023f1a..ddcbae0
Fast-forward
 README.md             | 36 +++++++++++++++---------------------
 catalog-api/server.js | 35 ++++++++++++++++++++++++++++-------
 orders-api/main.py    | 25 +++++++++++++++++--------
 3 files changed, 60 insertions(+), 36 deletions(-)
PS> git push origin main
   8023f1a..ddcbae0  main -> main
```

The push changes files in `catalog-api/` and `orders-api/` only. Light Cloud redeploys just those two. The web app stays on the commit it already runs, because nothing in `web/` changed.

![The web app's Production environment still on the Part 1 commit 8023f1a after the push](/assets/blog/microservices-on-light-cloud-part-2-connect-services/p2-01-web-not-redeployed.webp)

![catalog-api's Production environment deploying the Part 2 commit](/assets/blog/microservices-on-light-cloud-part-2-connect-services/p2-02-catalog-redeploying.webp)

You should see catalog-api and orders-api deploying the new commit, and web unchanged. This is the monorepo rule from Part 1 at work: each app has a **Root directory**, and a push only redeploys the apps whose folder it touched.

## Step 2: Allow more than one origin

A browser only lets a page read an API's answer if the API names that page's origin in its CORS header. Part 1 allowed one origin. Now `WEB_ORIGIN` can hold a comma-separated list. This is the new code in catalog-api:

```javascript title="catalog-api/server.js"
// One or more browser origins allowed to call this API, comma-separated:
// WEB_ORIGIN=https://main-web-myteam.light-cloud.io,http://localhost:5173
const WEB_ORIGINS = (process.env.WEB_ORIGIN || "http://localhost:5173")
  .split(",")
  .map((origin) => origin.trim())
  .filter(Boolean);

app.use(cors({ origin: WEB_ORIGINS }));
```

orders-api does the same in Python:

```python title="orders-api/main.py"
WEB_ORIGINS = [o.strip() for o in os.environ.get("WEB_ORIGIN", "http://localhost:5173").split(",") if o.strip()]
app.add_middleware(CORSMiddleware, allow_origins=WEB_ORIGINS, allow_methods=["*"], allow_headers=["*"])
```

Add your laptop's address to both APIs:

1. Open **catalog-api**, then **Production**, then the **Settings** tab.
2. In **Environment Variables**, click **Edit**.
3. Change `WEB_ORIGIN` to your web address and `http://localhost:5173`, separated by a comma and no spaces:

```text
https://main-web-yourworkspace.light-cloud.io,http://localhost:5173
```

![The WEB_ORIGIN variable with two origins and the Save button highlighted](/assets/blog/microservices-on-light-cloud-part-2-connect-services/p2-03-web-origin-two-origins.webp)

4. Click **Save**. Repeat for **orders-api**.

Saving redeploys the service. After about a minute, ask the API as if you were the local frontend. You should see the origin echoed back:

```shell os="macOS,Linux"
$ curl -s -D - -o /dev/null -H "Origin: http://localhost:5173" \
  https://main-catalog-api-yourworkspace.light-cloud.io/products | grep -i access-control
access-control-allow-origin: http://localhost:5173
```

```shell os="Windows"
PS> curl.exe -s -D - -o NUL -H "Origin: http://localhost:5173" `
  https://main-catalog-api-yourworkspace.light-cloud.io/products | Select-String access-control
access-control-allow-origin: http://localhost:5173
```

An origin that is not in the list gets no `Access-Control-Allow-Origin` header at all, so the browser blocks it. That is the behaviour you want: a list, never `*`, for an API that changes data.

## Step 3: Rotate the shared secret without downtime

catalog-api refuses calls to `/internal` routes unless they carry the shared secret. If you change the secret on both services at the same moment, there is a window where one has the new value and the other the old one, and orders fail. The fix is to let catalog-api accept two secrets for a short time.

This is how catalog-api checks the header now:

```javascript title="catalog-api/server.js"
// The current secret, plus the previous one while a rotation is in progress.
const INTERNAL_SECRET = process.env.INTERNAL_SECRET;
const INTERNAL_SECRET_PREVIOUS = process.env.INTERNAL_SECRET_PREVIOUS;

// Compares in constant time, so response timing does not leak the secret.
function sameSecret(expected, given) {
  if (!expected || !given) return false;
  const a = Buffer.from(expected);
  const b = Buffer.from(given);
  return a.length === b.length && timingSafeEqual(a, b);
}

function requireInternalSecret(req, res, next) {
  const given = req.get("x-internal-secret");
  if (sameSecret(INTERNAL_SECRET, given)) return next();
  if (sameSecret(INTERNAL_SECRET_PREVIOUS, given)) {
    log(req, "internal call used the previous secret");
    return next();
  }
  log(req, "rejected internal call");
  return res.status(401).json({ error: "Unauthorized" });
}
```

Make a new secret first. You should see one line of 48 random letters and digits; yours will be different. Keep it somewhere private for the next steps:

```shell os="macOS,Linux"
$ openssl rand -hex 24
cf91d1e26971527e2ec7362e9452f5abb4670e767ab1a0af
```

```shell os="Windows"
PS> -join ((1..24) | ForEach-Object { '{0:x2}' -f [System.Security.Cryptography.RandomNumberGenerator]::GetInt32(256) })
cf91d1e26971527e2ec7362e9452f5abb4670e767ab1a0af
```

### A. catalog-api accepts both secrets

1. Open **catalog-api**, **Production**, **Settings**, and click **Edit** under **Environment Variables**.
2. Set `INTERNAL_SECRET` to the **new** secret.
3. Click **Add** and create `INTERNAL_SECRET_PREVIOUS` with the **old** secret.
4. Click **Save**.

![catalog-api's variables with INTERNAL_SECRET and INTERNAL_SECRET_PREVIOUS highlighted and their values hidden](/assets/blog/microservices-on-light-cloud-part-2-connect-services/p2-04-rotate-a-catalog-both-secrets.webp)

orders-api still sends the old secret, and orders keep working. Open catalog-api's **Logs** tab and search for `previous secret`: every internal call made with the old value is listed.

![catalog-api's Logs tab filtered to lines saying internal call used the previous secret](/assets/blog/microservices-on-light-cloud-part-2-connect-services/p2-05-rotate-logs-previous-secret.webp)

### B. orders-api switches to the new secret

1. Open **orders-api**, **Production**, **Settings**, and click **Edit**.
2. Set `INTERNAL_SECRET` to the **new** secret and click **Save**.

![orders-api's INTERNAL_SECRET highlighted with the Save button](/assets/blog/microservices-on-light-cloud-part-2-connect-services/p2-06-rotate-b-orders-new-secret.webp)

When orders-api has redeployed, place an order in the shop. Search catalog-api's logs for `previous secret` again. You should see no new lines: orders-api now uses the new secret.

### C. catalog-api drops the old secret

1. Back in **catalog-api**, **Settings**, click **Edit**.
2. Click the **x** at the end of the `INTERNAL_SECRET_PREVIOUS` row.

![The INTERNAL_SECRET_PREVIOUS row highlighted, ready to be removed](/assets/blog/microservices-on-light-cloud-part-2-connect-services/p2-07-rotate-c-remove-previous.webp)

3. Confirm with **Remove**, then click **Save**.

![The Remove variable dialog for INTERNAL_SECRET_PREVIOUS with the Remove button highlighted](/assets/blog/microservices-on-light-cloud-part-2-connect-services/p2-08-rotate-c-confirm-remove.webp)

Once catalog-api has redeployed, the old secret no longer works. Put your old secret in the header (the example shows the one from Part 1). You should see `401`:

```shell os="macOS,Linux"
$ curl -s -o /dev/null -w "%{http_code}\n" -X POST \
  https://main-catalog-api-yourworkspace.light-cloud.io/internal/products/1/reserve \
  -H "content-type: application/json" \
  -H "x-internal-secret: cf91d1e26971527e2ec7362e9452f5abb4670e767ab1a0af" -d '{"quantity":1}'
401
```

```shell os="Windows"
PS> curl.exe -s -o NUL -w "%{http_code}\n" -X POST `
  https://main-catalog-api-yourworkspace.light-cloud.io/internal/products/1/reserve `
  -H "content-type: application/json" `
  -H "x-internal-secret: cf91d1e26971527e2ec7362e9452f5abb4670e767ab1a0af" -d '{"quantity":1}'
401
```

The same request with the new secret prints `200` (and reserves one item, so run it once). In our run, the old secret stopped working 70 seconds after saving, and no order failed during the whole rotation.

## Step 4: Fail fast when catalog-api is down

A call between services can hang: the other service may be starting up, overloaded or misconfigured. Without a limit, the customer waits for as long as the connection does. orders-api now gives catalog-api 5 seconds and then answers with a clear error:

```python title="orders-api/main.py"
# How long to wait for catalog-api before giving up on an order.
CATALOG_TIMEOUT_SECONDS = float(os.environ.get("CATALOG_TIMEOUT_SECONDS", "5"))

try:
    async with httpx.AsyncClient(timeout=CATALOG_TIMEOUT_SECONDS) as client:
        reply = await client.post(
            f"{CATALOG_API_URL}/internal/products/{order.product_id}/reserve",
            json={"quantity": order.quantity},
            headers={"x-internal-secret": INTERNAL_SECRET, "x-request-id": request_id},
        )
except httpx.HTTPError as error:
    log(request_id, "catalog-api unreachable", error=type(error).__name__)
    raise HTTPException(status_code=503, detail="Catalog service unavailable, please try again")
```

When catalog-api cannot be reached, an order now returns:

```json
{"detail": "Catalog service unavailable, please try again"}
```

with status `503`, and orders-api's logs show `catalog-api unreachable` with the error type. To change the limit, add `CATALOG_TIMEOUT_SECONDS` to orders-api's environment variables. Keep it well below the time a customer is willing to wait for a button click.

Why 5 seconds? catalog-api scales to zero when idle, and its first request after a quiet period includes a cold start. Part 4 measures that cold start, so you can set the timeout from a number instead of a guess.

## Troubleshooting

### Access-Control-Allow-Origin is missing for localhost

`WEB_ORIGIN` must list the exact origin: scheme, host and port, and no slash at the end. `http://localhost:5173` and `http://127.0.0.1:5173` are different origins. Check that the value has no spaces around the comma, save, and wait about a minute for the redeploy.

### Orders fail with 502 during the rotation

orders-api sent a secret catalog-api did not accept. Usually the order of the steps was swapped: orders-api got the new secret before catalog-api accepted it. Set `INTERNAL_SECRET_PREVIOUS` on catalog-api to whatever orders-api currently sends, and orders work again.

### "internal call used the previous secret" keeps appearing after step B

Some caller still sends the old secret. Check that orders-api's save finished and that its **Production** environment shows **Deployed**, then look for any other service or script that calls `/internal` routes.

### Orders return 503 "Catalog service unavailable, please try again"

orders-api could not reach catalog-api within `CATALOG_TIMEOUT_SECONDS`. Check `CATALOG_API_URL` on orders-api and that catalog-api's **Production** environment is **Deployed**. If catalog-api scaled to zero, the first order after a quiet period can take longer; Part 4 covers that.

## FAQ

### Can one API allow more than one CORS origin?

Yes. List every allowed origin in the API's CORS setting. In this tutorial `WEB_ORIGIN` holds a comma-separated list, for example the production site and `http://localhost:5173`.

### How do I change a shared secret between services without downtime?

Let the receiving service accept both the old and the new secret, switch the calling service to the new one, then remove the old one. At no point does a caller hold a secret the receiver refuses.

### Do I need to redeploy after changing an environment variable on Light Cloud?

No. Saving environment variables redeploys the service on its own. In our run the new values were live in 50 to 70 seconds.

### Why does only one service redeploy when I push to a monorepo?

Each Light Cloud app has a **Root directory**. A push only redeploys the apps whose folder the push changed.

### What should a service do when another service does not answer?

Stop waiting after a short timeout and return a clear error, such as `503 Service Unavailable`, instead of leaving the customer's request hanging.

## Next steps

- Part 1, if you skipped it: [deploy a frontend, two APIs and a database](/tutorials/microservices-on-light-cloud-part-1-deploy).
- Part 3: autoscaling explained with a load test.
- Part 4: scale to zero or always on, measured with real cold starts.
