BetterIAM
Authorization

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 needUse
Access that ends on a date: a contractor, an audit, a projectAn expiring binding
Access that begins later: the first day of a contractA future-dated binding
Team membership for a period, with every role the team holdsA temporary group membership
Access only during business hoursAn access window
People asking for a role, a reviewer approving it for a whileAccess requests
Privileged roles held only while needed, with justification and approvalJust-in-time elevation
A short session with exactly one role, possibly in another tenantRole sessions
A short-lived, narrowed copy of your own credential for a script or CLISession tokens
CI jobs and workloads that sign in with their platform's token, no stored secretWorkloads without stored secrets
Credentials for a machine that expire and can be scopedAPI 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
});
  • expiresAt is in epoch milliseconds, in the future, and within ten years (INVALID_INPUT otherwise).
  • An expired binding grants nothing from that instant. It disappears from identities.listBindings, roles.listBindings, and bindings.list (pass includeExpired: true to bindings.list to see it), and the purge worker deletes it.
  • bindings.update({ tenantId, bindingId, expiresAt }) extends or shortens the end, and expiresAt: null clears it. It follows the same rules as bindings.delete: the caller needs iam:bindings:create on 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's starting section) but grants nothing until then.
  • startsAt may not lie in the past (a minute of clock skew is tolerated) and must be within ten years and before expiresAt.
  • bindings.update moves the start, or clears it with startsAt: null so 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 (and groups.addMembers for several people) accepts expiresAt under the same rules as bindings: in the future and within ten years.
  • groups.updateMember({ tenantId, groupId, identityId, expiresAt }) extends or shortens the membership, and expiresAt: null makes it permanent.
  • groups.listMembers and identities.listGroups report membershipExpiresAt, 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:

  1. A platform administrator establishes a . trust.create records that one exact source identity may assume one exact target role. It is root only and needs recent authentication.
  2. The source identity assumes the role when needed. roles.assume checks the trust and returns a bearer token for the target tenant, its expiresAt, and a session summary that names the role and the trust.
Set up once (root administrator)
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
});
Assume the role when needed (the support engineer)
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:assume permission 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 its iam:roles:assume permission, 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:assume in the source tenant, and as role:assumed in 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, under iam: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.

A one-hour, read-only token for a deploy script
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 policy and by its source (an API key's scopes and authority carry over). It never outlives its source and ends when the source ends.
  • Passing mfaCode gives 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 most sts.maxSessionTokensPerIdentity live tokens (50 by default).
  • Policies see these credentials as principal.sessionKind: 'session-token', and sts.getCallerIdentity (the CLI's whoami) tells any holder what a token acts as.
  • With format: 'jwt' (and sts.jwt configured), the token is a signed JWT that other services can verify offline with createSessionTokenVerifier from better-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.create issues a key for an active service account. It requires iam:credentials:create on 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.
  • scopes compiles to a session policy that allows exactly those actions on every resource; policy accepts a full session policy instead. Passing both fails with INVALID_INPUT.
  • API key sessions are never MFA-verified (principal.mfa is false) and report principal.sessionKind as api-key.
  • credentials.rotate replaces the key material: the old key stops working in the same transaction, and the replacement keeps the label, policy, and expiry. credentials.revoke deletes a key at once, and credentials.update relabels 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

Was this page helpful?

Better IAM is created by Sean Filimon

Last updated

On this page