---
title: 'Short-Lived Credentials'
source: 'https://academia.sh/en/courses/runtime-security/short-lived-credentials'
course: 'Runtime Security and Secrets Management'
language: en
updated: '2026-10-07T04:31:06+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Defines and measures the exposure window: until the next rotation for a static secret, the remaining token life for a short-lived credential'
    - 'Separates the items whose window can be shortened from the items that never shrink and counts where the remaining exposure gathers'
    - 'Derives the cost of moving to short lives along three axes: the distribution of store requests, the generation volume on the target system, and outage resilience'
---

# Short-Lived Credentials

The time from the moment a secret leaks to the moment it expires on its own is measured: in 16 of 31 items the exposure window drops from 90 days to 64 minutes, a factor of 2,033; in the remaining 15 items it does not shrink at all, and after the move 99.96% of the total exposure gathers there. The cost is not the total of store requests but their distribution — the load of three apps grows by 6.3 to 14.5 times, 1,073 temporary credentials a day are born and die on the target systems, and the number of apps that go down in a two-hour store outage rises from four to eight.

The previous lesson narrowed the access matrix: of seventy-nine `(app, secret)` items, thirty-one
were actually read and forty-eight were unused grants. What sits in each of the remaining
thirty-one items was never looked at. That thing is **a value with no lifetime**: written on
installation day, the same from that day to this. This lesson measures that value along a single
axis — if the secret leaks, how long does it keep working.

The architecture of the secret storage service and how a dynamic credential generator is set up as
a service were measured in earlier courses and are not repeated here. The measure here is a single
duration: the **exposure window**. For a static secret this is the time until the next planned
rotation; for a short-lived credential generated on request it is the remaining token life. The
difference between the two is the subject of this lesson — and so is the set of items where that
difference never arises.

- **SM7.** The previous lesson's model continues: nine apps, one secret store, thirty-one read
  items, and seventeen distinct secrets. The environment is **fictional**, what is measured is a
  **model** written in `node`, and it **carries no real secret value**.
- **SM8.** The exposure window does not include the detection of the leak. Detection and the
  revocation flow are measured separately in the last lesson of this topic; here the time until
  the secret expires **on its own** is counted.
- **SM9.** For a static secret, the model assumes planned rotation is **done on time**. Late
  rotation and what happens during rotation are the subject of the next lesson.
- **SM10.** The moment of the leak is assumed random within the window: the mean exposure window
  is half the rotation period, and the worst case is all of it. The same rule holds for token
  life.
- **SM11.** Whether a target system can generate a credential on request is given in the model
  table. A signing key is not counted as generable: the party that verifies the signature must
  know the same key, so the key cannot be changed per request.
- **SM12.** There is no randomness; every number is derived from the model tables and the output
  is the same from run to run.

## How Many Times the Window Shrinks

```js
// lifetime.mjs — 31 (app, secret) items and the exposure window of each. A MODEL.
// The production environment of the regional measurement network is FICTIONAL: nine apps, one secret store.
// THERE ARE NO REAL SECRET VALUES: only secret paths, rotation periods, and token lives are modeled.
export const READS = {
  "portal-frontend": ["signing/session-signing"],
  "portal-api": ["data/portal-primary", "data/portal-read", "signing/session-signing",
    "signing/internal-call-signing", "infra/cache", "infra/log-write"],
  "subscriber-signup": ["data/portal-primary", "external/email-gateway", "external/sms-gateway"],
  "billing-service": ["data/billing-primary", "data/portal-read", "data/archive",
    "external/payment-gateway", "signing/document-signing"],
  "payment-bridge": ["external/payment-gateway"],
  "field-api": ["data/field-primary", "queue/work-order-queue", "external/map-service",
    "signing/internal-call-signing"],
  "work-order": ["data/field-primary", "queue/work-order-queue", "queue/notification-queue",
    "external/map-service"],
  "notification": ["queue/notification-queue", "external/email-gateway", "external/sms-gateway"],
  "report-generator": ["data/portal-read", "data/archive", "infra/object-store",
    "external/email-gateway"],
};
// domain default [rotation (days), generates, token life (min)]; generates = whether the target
// system can generate a short-lived credential on request. Exceptions are written separately.
const DOMAIN_DEFAULT = { data: [180, true, 60], queue: [180, true, 120], infra: [180, true, 240],
  signing: [90, false, 0], external: [365, false, 0] };
const EXCEPTIONS = { "infra/log-write": [180, true, 720], "infra/cache": [180, false, 0],
  "external/payment-gateway": [90, false, 0] };
export const SECRETS = Object.fromEntries([...new Set(Object.values(READS).flat())].map((s) => {
  const [rotation, generates, life] = EXCEPTIONS[s] || DOMAIN_DEFAULT[s.split("/")[0]];
  return [s, { rotation, generates, life }];
}));
// format/life: how the secret is obtained today. instances: running instances.
// restarts: restarts per day. requests: daily requests (fictional unit).
export const APPS = {
  "portal-frontend": { format: "at-startup", life: 0, instances: 4, restarts: 6, requests: 84000 },
  "portal-api": { format: "cached", life: 60, instances: 8, restarts: 12, requests: 96000 },
  "subscriber-signup": { format: "at-startup", life: 0, instances: 2, restarts: 3, requests: 1400 },
  "billing-service": { format: "cached", life: 240, instances: 2, restarts: 2, requests: 6200 },
  "payment-bridge": { format: "per-request", life: -1, instances: 3, restarts: 2, requests: 3400 },
  "field-api": { format: "cached", life: 60, instances: 5, restarts: 8, requests: 42000 },
  "work-order": { format: "at-startup", life: 0, instances: 3, restarts: 4, requests: 9600 },
  "notification": { format: "cached", life: 15, instances: 2, restarts: 3, requests: 12000 },
  "report-generator": { format: "at-startup", life: 0, instances: 1, restarts: 1, requests: 320 },
};
export const NAMES = Object.keys(APPS);
export const ITEMS = NAMES.flatMap((a) => READS[a].map((s) => [a, s]));
export const DOMAINS = ["data", "queue", "infra", "signing", "external"];
// exposure window: time until the secret expires on its own once it leaks (min).
export const exposure = (s, short) => short && SECRETS[s].generates ? SECRETS[s].life : SECRETS[s].rotation * 1440;
export const gen = (B) => (h) => h.map((v, j) => j ? String(v).padStart(B[j])
  : String(v).padEnd(B[0])).join("");
export const span = (min) => min < 1440 ? `${Math.round(min)} min` : `${(min / 1440).toFixed(1)} days`;
export const mean = (k, short) => k.reduce((t, [, s]) => t + exposure(s, short) / 2, 0) / k.length;

if (import.meta.url === `file://${process.argv[1]}`) {
  const row = gen([10, 8, 15, 16, 20, 7]);
  const D = ITEMS.filter(([, s]) => SECRETS[s].generates), S = ITEMS.filter(([, s]) => !SECRETS[s].generates);
  console.log(`model: ${ITEMS.length} (app, secret) items, ${Object.keys(SECRETS).length} read ` +
    `secrets, ${NAMES.length} apps (fictional production environment)\n`);
  console.log(row(["domain", "items", "shortenable", "window today", "short-lived window", "fold"]));
  for (const d of DOMAINS) {
    const k = ITEMS.filter(([, s]) => s.startsWith(d + "/"));
    console.log(row([d, k.length, k.filter(([, s]) => SECRETS[s].generates).length,
      span(mean(k, false)), span(mean(k, true)), (mean(k, false) / mean(k, true)).toFixed(0)]));
  }
  console.log(row(["total", ITEMS.length, D.length, span(mean(ITEMS, false)),
    span(mean(ITEMS, true)), (mean(ITEMS, false) / mean(ITEMS, true)).toFixed(1)]));

  console.log(`\nonly the ${D.length} shortenable items: mean window ` +
    `${span(mean(D, false))} -> ${span(mean(D, true))}, ` +
    `${Math.round(mean(D, false) / mean(D, true))}-fold`);
  console.log(`the remaining ${S.length} items: mean window ${span(mean(S, false))}, unchanged`);
  console.log(`exposure share after the move to short-lived — ` +
    `${S.length} static items: ${(100 * mean(S, true) * S.length /
      (mean(ITEMS, true) * ITEMS.length)).toFixed(2)}%`);
  const rest = [...new Set(S.map(([, s]) => s))].sort((x, y) => SECRETS[y].rotation - SECRETS[x].rotation);
  console.log(`${rest.length} secrets that could never move to short-lived: ` +
    rest.map((s) => `${s.split("/")[1]}(${SECRETS[s].rotation}d)`).join(" "));
}
```

```
model: 31 (app, secret) items, 17 read secrets, 9 apps (fictional production environment)

domain       items    shortenable    window today  short-lived window   fold
data            10             10       90.0 days              30 min   4320
queue            4              4       90.0 days              60 min   2160
infra            3              2       90.0 days           30.1 days      3
signing          5              0       45.0 days           45.0 days      1
external         9              0      151.9 days          151.9 days      1
total           31             16      100.7 days           54.3 days    1.9

only the 16 shortenable items: mean window 90.0 days -> 64 min, 2033-fold
the remaining 15 items: mean window 112.2 days, unchanged
exposure share after the move to short-lived — 15 static items: 99.96%
8 secrets that could never move to short-lived: email-gateway(365d) sms-gateway(365d) map-service(365d) cache(180d) session-signing(90d) internal-call-signing(90d) payment-gateway(90d) document-signing(90d)
```

The first two rows of the table give the real size of the short-lived credential. In the database
items the exposure window drops from ninety days to thirty minutes; the ratio is four thousand
three hundred twenty. In the queue items, two thousand one hundred sixty. Most defenses improve an
item by thirty or fifty percent; here the same item changes by three orders of magnitude at
once — between ninety days and thirty minutes there are already four orders of magnitude.

The last row reads the same table over the whole set, and the figure collapses. The mean over the
thirty-one items falls from 100.7 days to 54.3 days: **only 1.9-fold.** The same change, in the
same run, gives two thousand-fold and two-fold side by side. The difference is in the third column
of the table: of the thirty-one items, sixteen can be shortened and fifteen cannot.

The fifteen items that cannot be shortened fall into three classes. Signing keys (five items)
cannot be changed per request, because the party that verifies the signature must know the same
key; shortening the key's life breaks the verifying side. External gateway keys (nine items)
depend on what the provider gives, and in this model all four give a fixed key — the payment, map,
email, and SMS gateways. The cache server (one item) does not generate users on request.

The two rows after the fourth are the decision itself. After the move, **99.96%** of the total
exposure gathers in the fifteen items that cannot be shortened. In other words, moving to short
lives shrinks 0.04% of the risk by three orders of magnitude, without touching at all the items
that carry nearly all of it. This does not mean the work is worthless — when a database credential
leaks, what is taken is subscriber data, and a window dropping from ninety days to thirty minutes
is a real gain. What it means is this: **the mean exposure window is the wrong indicator.** The
right indicator is the list of items that cannot be shortened, and that list consists of eight
secrets.

## The Cost of Renewal

As token life shortens, renewal becomes more frequent; and the place renewal goes to is the single
point measured in the previous lesson.

```js
// load.mjs — the cost of moving to short lives: store requests, generation on the target, outage resilience
import { NAMES, READS, SECRETS, APPS, ITEMS, gen } from "./lifetime.mjs";

// daily store requests of one item. In the short-lived case the item renews on its own token life;
// a static item keeps the app's read pattern of today.
const requestCount = (a, s, short) => {
  const u = APPS[a];
  if (short && SECRETS[s].generates) return Math.round(1440 / SECRETS[s].life) * u.instances + u.restarts;
  if (u.format === "per-request") return u.requests;
  if (u.format === "cached") return Math.round(1440 / u.life) * u.instances + u.restarts;
  return u.restarts;
};
// how long an app holds out in a store outage: 0 for per-request readers, its cache life for cached
// ones, unlimited for at-startup readers. In the short-lived case the shortest token life caps it too.
const endurance = (a, short) => Math.min(
  APPS[a].format === "per-request" ? 0 : APPS[a].format === "cached" ? APPS[a].life : Infinity,
  ...(short ? READS[a].filter((s) => SECRETS[s].generates).map((s) => SECRETS[s].life) : []));

const row = gen([18, 8, 13, 13, 11, 6]);
console.log(row(["app", "items", "reqs today", "short-lived", "generation", "fold"]));
for (const a of NAMES) {
  const b = READS[a].reduce((t, s) => t + requestCount(a, s, false), 0);
  const k = READS[a].reduce((t, s) => t + requestCount(a, s, true), 0);
  const u = READS[a].filter((s) => SECRETS[s].generates).reduce((t, s) => t + requestCount(a, s, true), 0);
  console.log(row([a, READS[a].length, b, k, u, (k / b).toFixed(1)]));
}
const T = (short, f = () => true) => ITEMS.filter(([, s]) => f(s))
  .reduce((t, [a, s]) => t + requestCount(a, s, short), 0);
console.log(row(["total", ITEMS.length, T(false), T(true), T(true, (s) => SECRETS[s].generates),
  (T(true) / T(false)).toFixed(1)]));

console.log(`\ntemporary credentials generated per day on the target system (created, then expired):`);
for (const d of ["data", "queue", "infra"])
  console.log(`  ${d.padEnd(9)}${String(ITEMS.filter(([, s]) => s.startsWith(d + "/") &&
    SECRETS[s].generates).reduce((t, [a, s]) => t + requestCount(a, s, true), 0)).padStart(5)}`);

const row2 = gen([13, 22, 20]);
console.log(`\n${row2(["outage (min)", "apps down today", "short-lived down"])}`);
for (const D of [15, 60, 120, 240, 1440])
  console.log(row2([D, NAMES.filter((a) => endurance(a, false) < D).length,
    NAMES.filter((a) => endurance(a, true) < D).length]));
const resilient = NAMES.filter((a) => endurance(a, true) === Infinity);
console.log(`\napps that can run indefinitely in a store outage: today ` +
  `${NAMES.filter((a) => endurance(a, false) === Infinity).length}, short-lived ${resilient.length}` +
  `${resilient.length ? ` (${resilient.join(", ")})` : ""}`);
```

```
app                  items   reqs today  short-lived generation  fold
portal-frontend          1            6            6          0   1.0
portal-api               6         1224         1048        436   0.9
subscriber-signup        3            9           57         51   6.3
billing-service          5           70          178        150   2.5
payment-bridge           1         3400         3400          0   1.0
field-api                4          512          452        196   0.9
work-order               4           16          160        156  10.0
notification             3          585          417         27   0.7
report-generator         4            4           58         57  14.5
total                   31         5826         5776       1073   1.0

temporary credentials generated per day on the target system (created, then expired):
  data       863
  queue      175
  infra       35

outage (min)        apps down today    short-lived down
15                                1                   1
60                                2                   2
120                               4                   8
240                               4                   8
1440                              5                   8

apps that can run indefinitely in a store outage: today 4, short-lived 1 (portal-frontend)
```

The total row at first glance says the opposite of what was expected: daily store requests fall
from five thousand eight hundred twenty-six to five thousand seven hundred seventy-six, that is,
they do not change at all. It cannot be concluded that moving to short lives adds no load to the
store; the total hides two movements that cancel each other.

The app rows separate those two movements. Of the four apps whose load grows, three are the ones
that today read their secret **only at startup**: the report generator by fourteen and a half
times, the work order service by ten times, subscriber signup by six point three times. Today they
make four, sixteen, and nine requests a day; after the move to short lives, fifty-eight, one
hundred sixty, and fifty-seven. A value with no lifetime is read once, a value with a lifetime is
renewed constantly. In the three apps whose load falls, the reason is the reverse: the portal and
field APIs already refresh their cache every sixty minutes, and the token life is sixty minutes
too; the only thing that changes is the log write key moving to a twelve-hour life and the requests
for that item dropping from two hundred four to twenty-eight.

The second table shows the real new cost. The counterpart of one thousand seventy-three requests
is not a read but a **generation**: on the target systems, eight hundred sixty-three database
users, one hundred seventy-five queue credentials, and thirty-five infrastructure credentials are
created every day and cleaned up when they expire. This is the load of the database, not of the
secret store, and it is often forgotten in secrets management accounting; a flow that can create
but cannot delete produces an accumulation of eight hundred sixty-three items a day.

The third table is the most expensive item of the cost, and it directly worsens the finding of the
previous lesson. The number of apps that can hold out two hours in a store outage is four today;
after the move to short lives that number rises to eight — in other words, eight of the nine apps
go down in a two-hour store outage. The number of apps that can withstand an outage indefinitely
falls from four to one, and that one app is the portal frontend, which has no shortenable items at
all. Cutting the exposure window from ninety days to thirty minutes means cutting the app's
dependence on the store from ninety days to thirty minutes too; the two are two faces of the same
setting and cannot be chosen separately.

## A Cheaper Stage

The thirty-minute window is set up at runtime, but whether the window **can be shortened** is not
decided at runtime. Each of the eight secrets that cannot be shortened was chosen at an earlier
stage: the email and SMS gateways at the vendor selection stage, the signing keys at the design
stage, the cache server at the infrastructure selection. If the question "does it generate a
short-lived credential on request" was not asked when an external provider was chosen, that
provider's key stays static for the lifetime of the system; this lesson can measure that key but
cannot shorten it. All nine external items were born this way, and today they carry the largest
share of the total exposure.

The only decision that truly belongs to runtime is the choice of token life in the sixteen
shortenable items. That choice is not one-directional either: lowering the life from sixty minutes
to fifteen divides the window by four, multiplies the store requests by four, and divides the
outage endurance by four. The three are the same number.

## Summary

- The exposure window is the time from the moment a secret leaks to the moment it expires on its
  own: until the next planned rotation for a static secret, the remaining token life for a
  short-lived credential.
- In the 16 shortenable items the window falls on average from 90 days to 64 minutes, a factor of
  2,033; in the database items the ratio is 4,320.
- Over all thirty-one items the window narrows only 1.9-fold on average. After the move, 99.96% of
  the total exposure gathers in the 15 items that cannot be shortened. This is why the mean
  exposure window is the wrong indicator; the right one is the eight-secret list of what cannot be
  shortened.
- Daily requests to the store go from 5,826 to 5,776, that is, they do not change; but 1,073 of
  them are now generations rather than reads, and 863 database users are born and die every day on
  the target systems. The load of the three apps that read their secret only at startup grows by
  6.3, 10, and 14.5 times.
- The most expensive item of the cost is outage resilience: the apps that go down in a two-hour
  store outage rise from 4 to 8, and the ones that can hold out indefinitely fall from 4 to 1.
  Shortening the window and tightening the store dependency are the same setting.
- The eight secrets that cannot be shortened were fixed at the vendor and design choice stages; the
  runtime measures them but cannot shorten them.

## Next Step

This lesson showed that life cannot be shortened in fifteen items. For those items one tool is
left: **rotation**. If the secret does not shorten, it is changed at set intervals and the
exposure window is bounded by the rotation period. But rotation is not something that happens in an
instant. Every app that will read the new value has to move over, and apps do not move at the same
speed: one in sixty minutes, another only when it restarts. During that transition **two secrets
must stay valid together**, or the app that has not moved is cut off. The next lesson calculates
that time: for seventeen secrets, how many minutes the minimum overlap is, how many clients miss the
new value when the time is kept short, and how many legitimate requests are cut.
