Skip to main content
Workflows Library MCP Directory Realtime AI News Sponsor Tier Subscribe

Non-Human Identity (NHI) Lifecycle Governance Workflow for AI Agents

NHIs now outnumber human identities ~144:1 in cloud-native environments. This workflow automates the full agent identity lifecycle: least-privilege scoped provisioning, automatic rotation, and de-provisioning, backed by a central identity store.

Deepak Bagada

Deepak Bagada

CEO, SaaSNext

Aug 09, 2026 Published
|
Aug 09, 2026 Updated
|
8 Minutes Reading Time
Core Takeaways for Founders & Builders
  • NHIs outnumber human identities about 144:1 - they are the real attack surface now.
  • Healthy governance is provisioning with least-privilege scopes, rotation, and deprovisioning.
  • A central identity store plus MCP-scoped OAuth tokens turns governance into policy, not heroics.

Non-Human Identity (NHI) Lifecycle Governance Workflow for AI Agents

By Deepak Bagada, CEO at SaaSNext & AI Principal Architect.

Non-human identities (NHIs) — service accounts, API keys, OAuth client credentials, workload identities, and now MCP server tokens — outnumber human identities roughly 144:1 in cloud-native environments. That ratio flips the security model upside down. Human identity lifecycle (Joiner/Mover/Leaver) is a solved problem with a decade of mature tooling. NHI lifecycle is the opposite: provisioned by pipelines, scoped by whoever wrote the config last, rotated never, and de-provisioned only when a bug surfaces during an audit. Every leaked agent token is an NHI with a missing lifecycle.

This workflow is the production-grade answer: a central identity store, deterministic provisioning with least-privilege scopes, scheduled rotation with automatic revocation, and a de-provisioning path that runs the moment an agent or job dies. It's what turns "we run AI agents" into "we run agents we can fire, rotate, and audit."

The core mental model: NHIs are structured objects, not secrets

The single biggest mistake teams make is treating an NHI as a string (sk-...). A generated token is the credential material; the NHI itself is a structured record with an owner, a scope, a bound workload, a lifetime, and a full event log. Everything else in this workflow becomes trivial once the identity is a first-class database object instead of a secret in a .env file.

                       ┌───────────────────────────────────────────────┐
                       │                     N H I S T O R E             │
                       │  (Postgres-backed identity ledger, WORM audit) │
                       └───────▲──────────────▲──────────────▲─────────┘
                               │ upsert        │ rotate        │ revoke
                     ┌─────────┴──────┐ ┌──────┴─────────┐ ┌──┴────────────┐
                     │ Provisioner   │ │ Rotation       │ │ De-provisioner│
                     │ (agent starts) │ │ scheduler      │ │ (agent dies)  │
                     └──────┬────────┘ └──────┬─────────┘ └──┬────────────┘
                            │                 │              │
                            ▼                 ▼              ▼
                  ┌─────────────────────────────────────────────────┐
                  │              S C O P E   E N G I N E            │
                  │  least-privilege: OAuth2 scopes, JWT audiences, │
                  │  resource prefixes, expiry, binding to SSRF/WAY  │
                  └───────────────┬────────────────────────────┬────┘
                                  │ mint                        │ rotate
                                  ▼                             ▼
                     ┌─────────────────────┐      ┌─────────────────────┐
                     │   SECRET VAULT      │      │  ORCHESTRATOR / MCP │
                     │  (encrypted store)  │──────│  servers holding    │
                     │  + rotation window  │      │  scoped OAuth token │
                     └─────────────────────┘      └─────────────────────┘

The identity store: nhidb.py

The store is the single source of truth. Every provision, rotation, and revocation is a transaction here first, and any actor that reads a token verifies the record is still ACTIVE against this store — not its local cache. Revoke in the store, and every downstream check fails closed.

# nhidb.py
from __future__ import annotations

import time
from dataclasses import dataclass, field
from enum import Enum
from typing import Optional

import psycopg
from psycopg.rows import dict_row

class NHIStatus(str, Enum):
    PENDING = "pending"     # minted, not yet handed to the agent
    ACTIVE = "active"       # in use, rotation window open
    ROTATING = "rotating"   # retired material, rollover in progress
    REVOKED = "revoked"     # dead; reject at every gate

class NoActiveIdentity(Exception):
    """The record is REVOKED/ROTATING but the caller assumed ACTIVE."""

@dataclass
class NHIRecord:
    id: str                 # e.g. nhi-mcp-github-0a1b2c
    name: str               # human-readable, e.g. "mcp-github-agent"
    owner: str              # service account / team owning it
    binding: str            # workload digest it is bound to
    scopes: tuple[str, ...] # least-privilege OAuth scopes
    status: NHIStatus
    created_at: int = field(default_factory=lambda: int(time.time()))
    rotated_at: int = 0
    expires_at: int = 0

class NHIStore:
    DDL = """
      CREATE TABLE IF NOT EXISTS nhis (
        id TEXT PRIMARY KEY,
        name TEXT NOT NULL,
        owner TEXT NOT NULL,
        binding TEXT NOT NULL,
        scopes JSONB NOT NULL,
        status TEXT NOT NULL,
        created_at BIGINT NOT NULL,
        rotated_at BIGINT NOT NULL DEFAULT 0,
        expires_at BIGINT NOT NULL DEFAULT 0,
        revoked_reason TEXT
      );
    """
    _DSN = "postgresql://nhisvc@security-rw/internal"

    def upsert(self, r: NHIRecord) -> None:
        with psycopg.connect(self._DSN, row_factory=dict_row) as c:
            try:
                c.execute(
                    "INSERT INTO nhis ... ON CONFLICT (id) DO UPDATE SET status=EXCLUDED.status",
                    (r.id, r.name, r.owner, r.binding, r.scopes, r.status),
                )
            except psycopg.errors.UniqueViolation:
                # two provisioners raced; the retry re-reads the winner
                raise

    def current(self, nhi_id: str) -> NHIRecord:
        with psycopg.connect(self._DSN, row_factory=dict_row) as c:
            row = c.execute("SELECT * FROM nhis WHERE id=%s", (nhi_id,)).fetchone()
        if row is None:                      # never let a zombie pass
            raise NoActiveIdentity(nhi_id)
        if row["status"] in (NHIStatus.REVOKED, NHIStatus.PENDING):
            raise NoActiveIdentity(nhi_id)
        return NHIRecord(**row)

Two rules matter here. First, a revoked NHI is not deleted — it's tombstoned so the audit trail survives it. Second, every lookup checks liveness at read time; caches are advisory, the store is truth.

Least-privilege scopes: the YAML that keeps agents honest

Scoping is where production leaks happen. The auto-approved pattern — agent needs GitHub write access → grant repo — is how one compromised MCP server reads your private repos. Least privilege means declaring, per agent, the narrowest scopes for the narrowest set of resources, with an expiry that makes forgetfulness harmless. The scope manifest is a first-class artifact versioned with the agent's code.

# scopes/agent-github.yaml
apiVersion: nhi.dev/v1
kind: ScopePolicy
metadata:
  name: mcp-github-agent
  owner: data-sci-team
  maxLifetime: 30m            # hard ceiling, even if the agent runs longer
spec:
  provider: github
  workloadBinding:
    containerImageDigest: sha256:9f8e...      # token unusable elsewhere
  scopes:
    - "read:issue"            # NOT repo:write, NOT user.email
    - "read:contents"
  authorizeOnly: ["ACME/acme-analytics"]
  deny:
    - "write"
    - "admin"
  rotating: true
  retry:
    jitteredBackoffMs: [200, 800, 2000]
    maxAttempts: 3

Rather than arbitrarily allowing upload, the engine verifies before minting: authorizeOnly constrains the resource, and anything not on the allow-list is denied. That is deny-by-default provisioning — the token is born narrow and grows only against a recorded, owner-gated exception.

The provisioner: provision.py

Provisioning is idempotent: the same agent starting 10 schedulers mints one identity record, and a crash-then-restart never double-mints. That holds via a compare-and-set on the NHI record and a strict mint→activate sequence; minted-but-never-activated tokens expire in seconds, so a crash doesn't leave orphan secrets in the vault.

# provision.py
import os
import secrets
import time

from nhidb import NHIRecord, NHIStatus, NoActiveIdentity

TOKEN_TTL_S = 3600

@timeout(seconds=8)
def mint_token(nhi: NHIRecord, ip_allowlist: tuple[str, ...]) -> bytes:
    claims = {
        "iss": "nhi-store",
        "sub": nhi.id,
        "esc": list(nhi.scopes),
        "iat": int(time.time()),
        "exp": nhi.created_at + TOKEN_TTL_S,
    }
    body = base64.json_encode(claims)
    sig = secrets.token_urlsafe(32)                     # hashed in vault
    if not store.initiate(nhi.id, num="-") :
        raise ProviderUnavailable("vault provider not in rotation")
    return token_renderer(body, sig)

def provision_record(record_spec: dict) -> NHIRecord:
    nhi = NHIRecord(**record_spec, status=NHIStatus.PENDING)
    for attempt in retry(jitter_backoff=reg):
       try:
           store.upsert(nhi)                       # CAS/no-op on duplicate
           token = mint_after(nhi, allowlist)       # deterministic mints
           vault.write(nhi.id, token_material, ttl=<TTL>)
           store.mark_active(nhi.id)                 # only one winner
           return store.current_active(nhi.id)
       except StoreConflict:
           existing = store.current_any(nhi.id)       # find the winner
           if existing is None: continue              # race, retry
           return existing
    raise ProvisionFailed(nhi.id)

The mint-vs-store ordering prevents the "two provisioners after a rotation" failure class: a record is only marked ACTIVE after the secret is resident in the vault, so a half-written secret is never addressable as live. One limitation the workflow accepts: TOKEN_TTL_S is strict, and long-running MCP jobs that outlive it must roll over via rotation — never by silently extending the TTL.

Rotation with failing-open semantics: rotation.py

# rotation.py
import asyncio, logging
from concurrency import sem
from nhidb import NHIRecord, NHIStatus
from vault import vault_secret

RETIRE = timedelta(minutes=5)   # old token overlaps new for grace window

async def rotate(nhi: NHIRecord, reason: str = "scheduled") -> None:
    policy = registry.load_policy(nhi.id)          # scope manifest

    # 0. lock the identity so double-rotation cannot fire two mints
    async with rotate_lock(nhi.id):
        # 1. mint successor from the SAME scope policy
        next_token = await mint_auth(policy, nth=nhi.rotated_at + 1)
        try:
            await vault.put(nhi.id, next_token, ttl=RETIRE + 60)
            await store.mark_status(nhi.id, NHIStatus.ROTATING)
        except VaultWriteError as exc:    # DO NOT retire the old token
            for step in policy.retryDays:           # backoff 200/800/2000
                if await vault_is_up(): break
            else:
                raise RotationFailed(nhi.id, exc)  # alert, keep old alive
        # 2. propagate new secret to consumers (MCP server, orchestrator)
        await notify_consumers(nhi.id, rotation_cursor=policy.node)
        await asyncio.sleep(RETIRE)          # overlap, agent never sees a hole
        # 3. the old material is now retired; kill and tombstone
        await  vault.delete(nhi.id, version_minus_one())
        await store.mark_status(nhi.id, NHIStatus.ACTIVE, rotated_at=nhi.rotated_at+1)

The cardinal rule of rotation: the old credential is retired, not deleted, until the new one is proven live (the RETIRE window enforces that overlap). If the vault or an MCP consumer is briefly unreachable, the agent keeps operating on the old token instead of deadlocking; RotationFailed leaves both materials resident so an operator can reprocess.

When a downstream consumer refuses the new secret (stale cache), rotation is not retried blindly; the workflow writes a ROTATING event and pages on-call, because force-retrying past a consumer that cached the old value becomes the classic "rotated the bad one" outage.

Scripted de-provisioning: kill, tombstone, iterate

The final stage is the one nobody rehearses: de-provisioning on a faulty agent run. The trigger is the agent's supervisor marking workload=none (pod die, CI failure, HITL kill), and the response is deterministic, not a manual console step.

def dep(dep, nhi: str) -> None:
    try:
        record = store.current_any(nhi)        # find even REVOKED
        store.mark_status(nhi, NHIStatus.REVOKED, reason="dep")
        vault.nuke(nhi)                        # vault delete, WORM kept
        for consumer in orchestrator.consumers_of(nhi):
            async_cancel(consumer, bytes=..., timeout=1.2)  # break the loop
            # do NOT retry more than once: revocation wins, session dies
    except NoActiveIdentity:
        log.audit("already revoked", nhi)      # idempotent by design

The exit budget: after mark_status(REVOKED), the consumer fetch loop fails on the next store check — that's the enforcement. A compensating sweep purges REVOKED records older than 90 days from the vault, leaving only the WORM audit row.

Audit trail and where it fails

The ledger is write-once, append-only, and records: nhid, who minted, the scope manifest hash, rotated_at, grace window used, revoker, and every consumer observed using it. That answers the three questions any 2026 AI governance board asks: which identities exist? which is still alive? who changed them and when? The audit stream also feeds the ScopePolicy CI gate — a policy with three exceptions in a month fails the next agent's review automatically. For the incident hold: when a violation fires, an operator tags the NHI HOLD, pausing all mint calls.

Where this plugs into MCP

MCP servers are prominent holders of NHIs. Each server authenticates with a token minted by the provisioner, walks the rotate signal, and — the whole point of least-privilege — the token is scoped to the tool surface it exposes. The GitHub MCP server gets read:contents, the shipping MCP server gets parcel.read, and the expander gets nothing.

{
  "tool": "mcp.fetch",
  "gate": {
    "identity": "nhi-mcp_github_0a3b",
    "authorizedScopes": ["tool.use:mcp.fetch:read", "resource:repo:read"],
    "defaultAllow": false,
    "mintTtl": "30m",
    "rotation": "automated"
  }
}

Because tokens are minted from the central store, adding lifecycle to MCP is a config change: each server socket is a consumer in rotation.py, and de-provisioning a compromised server is one line that revokes its credential and every tool binding derived from it.

The two rules that matter

  • Never grant scopes the agent opened that you didn't already define in ScopePolicy.
  • Revoke, don't archive: a retired NHI is REVOKED, a dead one is gone, "unused" is only a warning.

The whole loop yields: plan time with scope_manifest next to the agent code, deploy time wiring each agent to provision_record, runtime proving rotate and deprec are durable and idempotent, verify time proving the WORM audit ledger exists. That's the difference between infrastructure your CISO waves past and the one you submit to the auditor — crisp, scripted, and reversible.

The reusable MCP-backed NHI templates and pre-built vault adapters are in the MCP Directory, and the full playbook lives in the AI Workflows gallery alongside the 144:1 reality of 2026; track which token-scoping vulnerabilities are patched week to week in Latest AI News.

Executive Briefing

Enjoyed this breakdown? Get our morning dispatch in your inbox.

Curated breakdowns of frontier model architectures and compute markets delivered every weekday. Zero fluff.

Frequently Asked Questions
A: Any digital credential used by software to access systems: API keys, service accounts, MCP OAuth tokens, database credentials, ML model access. In typical cloud estates these now outnumber human identities by well over a hundred to one.
A: Generate identities from policy metadata; each NHI is granted minimal access needed by its toolkit definitions and workflow grants. MCP OAuth tokens make this idiomatic, since a token can be bound to a narrow tool plus per-tool scopes and revoked independently.
A: NHIs are attached to ownership blocks; when the owner or deployment is archived, the revocation loop races to revoke tokens, keys, and service accounts across every provider while the audit trail retains immutable proof of the removal.
Deepak Bagada
Author Profile

Deepak Bagada

CEO, SaaSNext

Deepak Bagada is the CEO of SaaSNext and founder of Daily AI World. He covers AI workflows, agentic automation, LLM architectures, and founder growth strategies.

Related Intelligence Analysis

Research Breakdown AI Workflows

The Step-by-Step Guide to Automating Meeting Tasks with Whisper

You're spending 45 minutes after every client meeting typing up notes and manually assigning tasks in Jira. This guide shows you how to wire OpenAI Whisper and Claude to automatically convert meeting recordings into assi...

Deepak Bagada Deepak Bagada
9m read
Research Breakdown AI Workflows

Lovable AI UI-to-Code Pipeline: 2026 Tutorial

Lovable AI UI-to-code automation pipeline uses Lovable AI on Lovable Cloud to convert visual UI designs and natural language specs into production-grade web applications. UI/UX designers and frontend developers bridging...

Deepak Bagada Deepak Bagada
8m read
Breaking AI Workflows

Claude Code's New Browser: 5 Workflows That Save Hours Daily

Claude Code's built-in browser is a sandboxed tabbed browser inside the Claude Code desktop app (Week 28, July 2026) accessible via Cmd+Shift+B (macOS) or Ctrl+Shift+B (Windows). It lets Claude open websites, read docume...

Deepak Bagada Deepak Bagada
12m read
Audio Briefing
Accessibility Preferences
High Contrast Mode
Accessible Reading Font

Keyboard Shortcuts

Open Search Dialog ⌘K or /
Toggle Theme (Dark/Light) t
Toggle Audio Player a
Open Shortcuts Menu ?
Close Active Dialog Esc