Don't hand someone the master key and hope they hand it back — lend it for the job, then take it back automatically when the clock runs out.
Just-in-time (JIT) privileged access replaces standing admin rights with a request. Instead of carrying elevated access all the time, an engineer asks for a specific scope and a short window. A reviewer (or a policy) approves it, a time-bound grant is issued, the work gets done, and the grant auto-expires — access is revoked without anyone having to remember.
This is the principle of least privilege made temporal: not just who can do what, but for how long. The goal is to shrink standing privilege to almost nothing, so the blast radius of a stolen credential or a careless action is small and short-lived.
By default the engineer holds only read-only rights — that is their standing privilege. To do privileged work they request a narrow scope for a short window. A reviewer or a policy approves (or denies), and on approval the system issues a grant with an expires_at timestamp. Every authorisation check then asks: is there an active, unexpired grant for exactly this scope? A background sweeper revokes grants the moment they expire. Every transition — request, approve/deny, grant, expiry — writes an audit entry, so you can always answer "who had access, to what, and when."
def request_access(user, scope, ttl):
grant = approve(user, scope) # peer/manager or policy
if not grant.approved:
audit(user, scope, "denied")
return None
grant.expires_at = now() + ttl # time-boxed
audit(user, scope, "granted", grant.expires_at)
return grant
def is_allowed(user, action):
g = active_grant(user, action.scope) # must match scope
return g is not None and now() < g.expires_at
def sweep(grants): # runs continuously
for g in grants:
if now() >= g.expires_at:
revoke(g) # access removed automatically
audit(g.user, g.scope, "expired")
The engineer never gains standing admin. They borrow exactly the scope they asked for, only for the window they asked for, and the system reclaims it on its own — no cleanup step to forget.
| Lever | Effect | Watch |
|---|---|---|
| Short TTL | Minimal exposure window, small blast radius | More re-requests, more friction mid-task |
| Auto-approve by policy | Fast — no human in the loop | Weaker control; lean on tight scope + audit |
| Narrow scope | Grant can't be reused for other systems | May need several grants for one job |
| Standing access | Always convenient, zero wait | Large, permanent attack surface |
expires_at turns a time-box into a forever-box. The sweeper can only revoke what has a real expiry.admin: * when the task needed one database widens the blast radius far past the work.At 2am an on-call engineer is paged: a production database is rejecting writes. They hold read-only access by default, so they file a JIT request for db-prod:write with a 30-minute TTL, noting the incident ticket. The secondary on-call approves it in one tap, an audit entry records the grant and its expires_at, and the engineer's badge flips to "admin (expires 02:31)." They run the fix, writes recover, and they close the incident. They don't have to remember to drop access — at 02:31 the sweeper revokes the grant, writes an "expired" audit line, and their badge returns to read-only. Standing privilege for the night: zero. The exposure window: thirty minutes, fully logged.
A grant is issued with a 15-minute TTL but the engineer's task runs long and they're still working at minute 20. What does a correct JIT system do?