Temporary access
Access that ends, starts later, or applies in business hours, plus access requests, role sessions, web-identity federation for workloads, and expiring API keys.
Access tends to pile up. A contractor finishes, an audit ends, someone moves teams, and the roles they were given stay behind because nobody remembers to remove them. The cheapest access to review is access that ends by itself. Better IAM lets you put an end, a start, or a schedule on almost every grant, and gives people a way to ask for access that is time-boxed from the start.
| You need | Use |
|---|---|
| Access that ends on a date: a contractor, an audit, a project | An expiring binding |
| Access that begins later: the first day of a contract | A future-dated binding |
| Team membership for a period, with every role the team holds | A temporary group membership |
| Access only during business hours | An access window |
| People asking for a role, a reviewer approving it for a while | Access requests |
| Privileged roles held only while needed, with justification and approval | Just-in-time elevation |
| A short session with exactly one role, possibly in another tenant | Role sessions |
| A short-lived, narrowed copy of your own credential for a script or CLI | Session tokens |
| CI jobs and workloads that sign in with their platform's token, no stored secret | Workloads without stored secrets |
| Credentials for a machine that expire and can be scoped | API keys |
Expiring bindings
A gives a to a person or group. By default it lasts
until someone removes it. Give it an expiresAt when you already know when the access should end:
await iam.api.bindings.create(credential, {
tenantId,
roleId: auditor.id,
subjectType: 'identity',
subjectId: externalAuditor.id,
expiresAt: Date.parse('2026-12-31T23:59:59Z'), // epoch milliseconds
});expiresAtis in epoch milliseconds, in the future, and within ten years (INVALID_INPUTotherwise).- An expired binding grants nothing from that instant. It disappears from
identities.listBindings,roles.listBindings, andbindings.list(passincludeExpired: truetobindings.listto see it), and the purge worker deletes it. bindings.update({ tenantId, bindingId, expiresAt })extends or shortens the end, andexpiresAt: nullclears it. It follows the same rules asbindings.delete: the caller needsiam:bindings:createon the role and must own the binding's , or be root.
Future-dated bindings
Sometimes you know access should start later: a new hire's first day, or a contract that begins next month.
Creating the binding now, with startsAt, means nobody has to remember to do it on the day.
await iam.api.bindings.create(credential, {
tenantId,
roleId: engineer.id,
subjectType: 'identity',
subjectId: newHire.id,
startsAt: Date.parse('2026-10-01T08:00:00+02:00'),
expiresAt: Date.parse('2027-03-31T18:00:00+02:00'), // optional; must be after startsAt
});- A future-dated binding is listed with its start (in
identities.listBindings,roles.listBindings, and the access report'sstartingsection) but grants nothing until then. startsAtmay not lie in the past (a minute of clock skew is tolerated) and must be within ten years and beforeexpiresAt.bindings.updatemoves the start, or clears it withstartsAt: nullso the binding applies at once.- Separation-of-duties rules count future-dated bindings, so a conflict scheduled to start later is refused now.
Temporary group memberships
When access comes from a , it is often the membership that should end: "join the incident team for this week". A temporary membership ends at a set time, and with it every grant and activation the membership carried.
await iam.api.groups.addMember(credential, {
tenantId,
groupId: incidentTeam.id,
identityId: alice.id,
expiresAt: Date.now() + 7 * 86_400_000,
});groups.addMember(andgroups.addMembersfor several people) acceptsexpiresAtunder the same rules as bindings: in the future and within ten years.groups.updateMember({ tenantId, groupId, identityId, expiresAt })extends or shortens the membership, andexpiresAt: nullmakes it permanent.groups.listMembersandidentities.listGroupsreportmembershipExpiresAt, so member lists can show when someone leaves.- Re-adding a lapsed member renews the membership, and the purge worker removes lapsed records (reported as
expiredMemberships).
Access windows
Some access should only exist during working hours: a support role that should not be usable at 3 a.m., or contractors who work a fixed schedule. An on a binding limits it to recurring hours in a time zone. Outside the window the binding grants nothing, exactly like an expired one, and it starts applying again when the window next opens.
await iam.api.bindings.create(credential, {
tenantId,
roleId: support.id,
subjectType: 'group',
subjectId: supportTeam.id,
window: { from: '09:00', to: '17:00', timeZone: 'Europe/Berlin', days: [1, 2, 3, 4, 5] },
});
// Change it, or clear it with window: null
await iam.api.bindings.update(credential, { tenantId, bindingId, window: null });Prop
Type
identities.listBindings reports inWindow for windowed bindings, reviews such as policies.whoCan and
policies.effectiveActions reflect the window at evaluation time, and configuration sync carries window on
group bindings. A policy condition cannot express recurring hours (DateBefore and DateAfter compare fixed
instants), so windows are the tool for schedules.
Access requests
Without a request flow, people ask for access in chat, an administrator binds a role by hand, and nobody remembers to remove it. An turns that into a recorded, time-boxed decision: a member asks for roles with a reason, a reviewer approves or denies, and approved access can expire by itself.
A member asks
A member with iam:access-requests:create calls accessRequests.create, from an ordinary session of the tenant
(not a role session):
const request = await iam.api.accessRequests.create(memberCredential, {
tenantId,
roleIds: [reportsViewer.id],
justification: 'Quarter-end close, ticket FIN-2231',
durationSeconds: 14 * 86_400, // optional: how long the access should last
});A request names 1 to 20 roles. Protected roles, such as Owner, cannot be requested. A member may hold at most
twenty pending requests (TOO_MANY_REQUESTS), and an identical pending request is rejected (CONFLICT). Requests
expire after accessRequests.lifetimeMs (default seven days).
A reviewer decides
A reviewer with iam:access-requests:review calls accessRequests.approve or accessRequests.deny, with an
optional note:
await iam.api.accessRequests.approve(reviewerCredential, {
tenantId,
requestId: request.id,
durationSeconds: 7 * 86_400, // optional: overrides the requested duration
note: 'Approved for the close only',
});Approval creates (or refreshes) one binding per role under the reviewer's own grant authority, and requires
iam:bindings:create on each role, exactly like bindings.create. A reviewer can therefore never grant more than
they could bind directly. A requester cannot approve their own request, and the requester must still be active.
The resulting bindings carry accessRequestId, and expiresAt whenever a duration was requested or set by the
reviewer; without a duration the access is standing until someone removes it.
The member follows up
Requesters see their own requests with accessRequests.listMine, which needs only iam:access-requests:create,
and may withdraw a pending one with accessRequests.cancel. Administrators list every request with
accessRequests.list and read one with accessRequests.get, both under iam:access-requests:read.
Two deployment options shape the flow:
Prop
Type
A request reads as expired as soon as its lifetime passes, even before the purge worker marks it, and deciding
a request that is no longer pending fails with INVALID_TRANSITION. Approvals and denials are audited as
access-request:approve and access-request:deny, with the requester, the roles, and the resulting binding IDs in
the metadata.
Just-in-time elevation and packages
Two larger features build on temporary access and have their own guides:
- record that someone may hold a privileged role. The person activates it for a bounded time, optionally with a justification, MFA, and an approver's decision, and the role stops applying when the activation ends. See Just-in-time elevation.
- bundle roles and group memberships that are granted together, with an end date, by assignment, by request, or automatically by rule. See Access packages.
Role sessions
A binding gives someone a role for as long as it lasts. Sometimes you want less: a support engineer who needs a customer tenant's support role for fifteen minutes, or an automation that should act with exactly one role and nothing else it holds. covers this. A trusted identity starts a short role session whose permissions are exactly the target role's.
Two steps set it up:
- A platform administrator establishes a .
trust.createrecords that one exact source identity may assume one exact target role. It is root only and needs recent authentication. - The source identity assumes the role when needed.
roles.assumechecks the trust and returns a bearertokenfor the target tenant, itsexpiresAt, and asessionsummary that names the role and the trust.
const trust = await iam.api.trust.create(rootCredential, {
tenantId: customerTenantId, // the tenant that owns the role
sourceTenantId: rootTenantId,
sourceIdentityId: supportEngineer.id,
roleId: customerSupportRole.id,
requireMfa: true, // the default
externalId: 'ticket-system', // optional shared value the caller must present
});const { token, session } = await iam.api.roles.assume(engineerCredential, {
tenantId: customerTenantId,
trustId: trust.id,
externalId: 'ticket-system',
durationSeconds: 900,
sessionName: 'ticket-4711', // optional label, visible to policies and the audit trail
});
// Use { token } as the credential for calls in the customer tenant until session.expiresAt.The trust's options decide how strict assumption is:
Prop
Type
And roles.assume shapes the session itself:
Prop
Type
- Assumption also requires the source identity's
iam:roles:assumepermission on the target role (iam/{targetRoleId}), evaluated in the source identity's own tenant. The trust must name the caller as its source identity and tenant, MFA must be present when the trust requires it, and the external ID must match. - A role can be assumed from an ordinary user session, an API key, or a session token. Role sessions cannot
chain: assuming a role from a role session fails with
ROLE_CHAINING_DISABLED. An impersonation session cannot assume roles either (IMPERSONATION_RESTRICTED). Protected roles cannot be the target of a trust. - The target role's policies replace the source identity's application permissions. The target tenant's boundaries, the trust ceiling, and the session policy still constrain them, and the session carries no groups, no relationships, and no owner or root flags.
- The session keeps the MFA state and sign-in time of the credential that started it, and policies see the
caller's own tenant as
principal.sourceTenantId. See Special sessions for the full context. - A role session is re-checked on every request. It stops working as soon as the trust is revoked
(
trust.revoke, root only), the source credential or identity stops working, the source identity loses itsiam:roles:assumepermission, or a grant authority behind the role or the source's own grants is revoked. - The target tenant's IP allowlist and network blocks apply to the address presenting the token and to the address it was issued from.
- Assumption is audited twice: as
iam:roles:assumein the source tenant, and asrole:assumedin the target tenant with the trust, source tenant, duration, format, and tag keys, so the target tenant sees who came in. trust.list({ tenantId, includeRevoked? })shows the trusts that target a tenant's roles, underiam:trust:read, without their external ID hashes.
The deployment-wide limits for role sessions live under sts in the server options; see
Temporary credentials.
Session tokens
A command-line tool or a script should not carry someone's full browser session or a long-lived API key around. A session token is a short-lived copy of your own credential for exactly that: you trade a signed-in session or an API key for a token that lasts an hour (or up to the configured limit), optionally narrowed by a policy, and hand the token to the script. If it leaks, it expires on its own.
const { token, expiresAt } = await iam.api.sts.getSessionToken(
{ token: signedInToken },
{
mfaCode: '123456', // optional: a fresh authenticator code
sessionName: 'deploy-cli',
policy: {
version: 1,
statements: [{ effect: 'allow', actions: ['iam:roles:assume', 'documents:read'], resources: ['*'] }],
},
},
);- The token acts with your own grants, bounded by
policyand by its source (an API key's scopes and authority carry over). It never outlives its source and ends when the source ends. - Passing
mfaCodegives the token a fresh MFA time. Role assumption cannot take a code itself, so this is how a command-line tool assumes a role whose trust requires MFA: MFA first, then assume. - Tokens cannot chain: a role session or another session token cannot mint one (
CREDENTIAL_CHAINING_DISABLED). Each identity holds at moststs.maxSessionTokensPerIdentitylive tokens (50 by default). - Policies see these credentials as
principal.sessionKind: 'session-token', andsts.getCallerIdentity(the CLI'swhoami) tells any holder what a token acts as. - With
format: 'jwt'(andsts.jwtconfigured), the token is a signed JWT that other services can verify offline withcreateSessionTokenVerifierfrombetter-iam/session-tokens.
Workloads without stored secrets
CI pipelines and cloud workloads traditionally hold an API key in a secret store, and a leaked key works from anywhere until someone notices. Most platforms already give their jobs a short-lived OpenID Connect token that proves which job, repository, or pod is running: GitHub Actions, GitLab CI, Kubernetes service accounts, and cloud workload identity services all do. Web-identity federation lets such a job trade that token for a Better IAM role session, so no IAM secret is stored anywhere.
Enable web identity
Set sts: { webIdentity: { enabled: true } } in the server options. It is off by default; see
web-identity federation for the fetch limits and
issuer pinning.
Register the provider
An administrator records whose tokens the organization accepts, and for which audience.
const github = await iam.api.oidcProviders.create(credential, {
tenantId,
name: 'GitHub Actions',
issuer: 'https://token.actions.githubusercontent.com',
audiences: ['https://iam.example.com'],
});Create a web-identity trust
The trust names the role, the service account the sessions act as, and conditions on the token's claims. The
conditions must pin token.sub, so a trust can never admit every job on the platform (WEAK_TRUST_CONDITIONS).
await iam.api.trust.create(credential, {
tenantId,
kind: 'web-identity',
providerId: github.id,
serviceAccountId: deployBot.id,
roleId: deployRole.id,
conditions: { StringEquals: { 'token.sub': 'repo:acme/app:ref:refs/heads/main' } },
maxSessionSeconds: 900,
});Exchange the token in the job
The job asks its platform for a token and calls the public
sts.assumeRoleWithWebIdentity. The answer is a role session
that lasts minutes, not months.
ID_TOKEN=$(curl -sH "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=https://iam.example.com" | jq -r .value)
curl -s https://iam.example.com/api/iam/sts/assumeRoleWithWebIdentity \
-H 'Content-Type: application/json' -H 'X-Better-IAM: 1' \
-d "{\"tenantId\":\"$TENANT_ID\",\"trustId\":\"$TRUST_ID\",\"webIdentityToken\":\"$ID_TOKEN\",\"sessionName\":\"deploy-$GITHUB_RUN_ID\"}"Every refusal that depends on the token or on stored state answers the same WEB_IDENTITY_REJECTED, so a caller
cannot probe which trusts exist. Only a malformed request, a spent exchange budget, or a switched-off feature is
refused earlier with its own code, and only a token already admitted by the trust can meet the ordinary limits (such
as network rules or the session cap). Administrators
see the real reason with trust.evaluateWebIdentity, which checks a
token against a trust without issuing anything. A token can be redeemed once unless the provider turns replay
protection off. Disabling the provider, revoking the trust, or disabling the service account ends the sessions at
once, and oidcProviders.revokeSessions ends them without
changing anything else.
API keys
A service or integration needs a credential that does not depend on a person's session and does not live forever. API keys are opaque credentials for service accounts, and every key has an expiry and can be scoped below what its service account may do.
const { token, credentialId, expiresAt } = await iam.api.credentials.create(credential, {
tenantId,
identityId: reportingService.id,
name: 'Nightly export',
scopes: ['reports:read', 'reports:export'], // or a full session `policy`
expiresInSeconds: 30 * 86_400, // default 90 days
});
// `token` is shown once; store it in your secret manager.credentials.createissues a key for an active service account. It requiresiam:credentials:createon the account, recent authentication, and a grant authority. Keys default to a 90-day expiry (60 seconds to 365 days).- The key retains its issuing authority's ceiling. If that authority is revoked, every request with the key is denied.
scopescompiles to a session policy that allows exactly those actions on every resource;policyaccepts a full session policy instead. Passing both fails withINVALID_INPUT.- API key sessions are never MFA-verified (
principal.mfaisfalse) and reportprincipal.sessionKindasapi-key. credentials.rotatereplaces the key material: the old key stops working in the same transaction, and the replacement keeps the label, policy, and expiry.credentials.revokedeletes a key at once, andcredentials.updaterelabels it or moves its expiry (in the future, at most a year out).
Finding unused keys, labels, and offboarding are covered in Access lifecycle.
Next steps
Better IAM is created by Sean Filimon
Last updated
Separation of duties
Declare roles nobody may hold together, block every grant that would combine them, and report the conflicts that already exist.
Privileged access
Keep standing privilege low and access time-bound with eligible roles, expiring identities, access packages, reports, and configuration as code.