> ## Documentation Index
> Fetch the complete documentation index at: https://docs.basaltic.sh/llms.txt
> Use this file to discover all available pages before exploring further.

# Assume role with web identity

> Exchange an identity token issued by a federation provider this platform
trusts for temporary credentials. The result is the same assumed-role
session `POST /v1/assume-role` mints, and is used the same way.

This request carries **no signature**, and it is the only
credential-vending call that does not. A federated caller holds no
Basaltic credential yet — that is what the exchange is for — so the token
in the body *is* the credential being presented. A signature sent anyway
is ignored, and nothing is taken from the request context: `role_id` and
`account_id` are read from the body like every other field.

That does not leave the endpoint open. Two independent gates have to
pass, and they fail differently.

**The token has to verify.** This happens before any role is read, so a
forged token never reaches a trust policy. The signature must chain to a
key the provider publishes, the audience must be the one this platform
accepts, and `exp` must be in the future. A wrong signer, a token minted
for some other consumer, and an expired token all answer `401` with the
same message — the response does not say which check failed.

**The role has to agree.** Verifying the token establishes who is
calling; it grants nothing. The role named in `role_id` is assumable only
if its own trust policy admits this identity. Its `principals` must name
the federation provider, written `crn:iam:::oidc-provider/<provider>` —
the one case where a trust policy principal is not the caller's own CRN,
because a federated identity has no CRN and what is trusted is the source
that vouched for it. Every entry in `conditions` must then hold against
the token's claims: `basalt:webidentity:Subject` carries the token's
`sub` and `basalt:webidentity:Audience` its `aud`, so a role can bind one
identity instead of accepting everything that provider will ever issue. A
condition on a claim the token does not carry fails closed.

A role whose trust policy names no provider therefore cannot be assumed
this way at all, however good the token is. That is the line between the
two failures: `401` means the token is not trustworthy, `403` means it is
and the role still will not have it.

The credentials come back scoped to `account_id`, carrying the role's own
permissions. There is no `policy` field here — unlike
`POST /v1/assume-role`, a federated session cannot be scoped down at
exchange time, so the role's attached policies are the whole grant. Size
the role accordingly.

Because it takes no credentials, requests are rate-limited per client IP.

Which providers are trusted is part of the platform's own configuration.
There is no API for registering an identity provider of your own yet, so
this operation is live but has no external provider whose tokens it would
accept; the roles that use it today are platform-managed.




## OpenAPI

````yaml /api-reference/specs/iam.yaml post /v1/assume-role-with-web-identity
openapi: 3.0.3
info:
  title: Basaltic IAM API
  version: 1.0.0
  description: |
    Identity for the platform: organizations, accounts, users, groups,
    service accounts, roles and policies, together with the sign-in flows and
    the temporary STS credentials every other Basaltic API authenticates
    against.
  contact:
    name: Basaltic Support
    email: ping@basaltic.sh
  license:
    name: Proprietary
    url: https://basaltic.sh/terms
servers:
  - url: https://iam.basaltic.sh
    description: Global API endpoint
security:
  - SignatureAuth: []
paths:
  /v1/assume-role-with-web-identity:
    post:
      tags:
        - IAM
      summary: Assume role with web identity
      description: >
        Exchange an identity token issued by a federation provider this platform

        trusts for temporary credentials. The result is the same assumed-role

        session `POST /v1/assume-role` mints, and is used the same way.


        This request carries **no signature**, and it is the only

        credential-vending call that does not. A federated caller holds no

        Basaltic credential yet — that is what the exchange is for — so the
        token

        in the body *is* the credential being presented. A signature sent anyway

        is ignored, and nothing is taken from the request context: `role_id` and

        `account_id` are read from the body like every other field.


        That does not leave the endpoint open. Two independent gates have to

        pass, and they fail differently.


        **The token has to verify.** This happens before any role is read, so a

        forged token never reaches a trust policy. The signature must chain to a

        key the provider publishes, the audience must be the one this platform

        accepts, and `exp` must be in the future. A wrong signer, a token minted

        for some other consumer, and an expired token all answer `401` with the

        same message — the response does not say which check failed.


        **The role has to agree.** Verifying the token establishes who is

        calling; it grants nothing. The role named in `role_id` is assumable
        only

        if its own trust policy admits this identity. Its `principals` must name

        the federation provider, written `crn:iam:::oidc-provider/<provider>` —

        the one case where a trust policy principal is not the caller's own CRN,

        because a federated identity has no CRN and what is trusted is the
        source

        that vouched for it. Every entry in `conditions` must then hold against

        the token's claims: `basalt:webidentity:Subject` carries the token's

        `sub` and `basalt:webidentity:Audience` its `aud`, so a role can bind
        one

        identity instead of accepting everything that provider will ever issue.
        A

        condition on a claim the token does not carry fails closed.


        A role whose trust policy names no provider therefore cannot be assumed

        this way at all, however good the token is. That is the line between the

        two failures: `401` means the token is not trustworthy, `403` means it
        is

        and the role still will not have it.


        The credentials come back scoped to `account_id`, carrying the role's
        own

        permissions. There is no `policy` field here — unlike

        `POST /v1/assume-role`, a federated session cannot be scoped down at

        exchange time, so the role's attached policies are the whole grant. Size

        the role accordingly.


        Because it takes no credentials, requests are rate-limited per client
        IP.


        Which providers are trusted is part of the platform's own configuration.

        There is no API for registering an identity provider of your own yet, so

        this operation is live but has no external provider whose tokens it
        would

        accept; the roles that use it today are platform-managed.
      operationId: assumeRoleWithWebIdentity
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AssumeRoleWithWebIdentityRequest'
      responses:
        '200':
          description: Role assumed successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AssumeRoleResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          description: >
            The token did not verify — an untrusted signer, an audience this

            platform does not accept, or an expired token. One message covers
            all

            three.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  code: UNAUTHORIZED
                  message: invalid web identity token
                  request_id: 550e8400-e29b-41d4-a716-446655440000
        '403':
          description: |
            The token verified, but the role's trust policy does not admit it —
            the provider is not among its `principals`, or a condition on the
            token's claims did not hold.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  code: IAM_CANNOT_ASSUME_ROLE
                  message: You are not authorized to assume this role
                  request_id: 550e8400-e29b-41d4-a716-446655440000
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
        '501':
          description: >
            Web identity federation is not configured in this region, so there
            is

            no provider to verify a token against.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  code: NOT_IMPLEMENTED
                  message: web identity federation is not configured
                  request_id: 550e8400-e29b-41d4-a716-446655440000
      security: []
components:
  schemas:
    AssumeRoleWithWebIdentityRequest:
      type: object
      description: >
        The exchange a federated caller sends. It carries no signature — the
        token

        is the credential — so every field is read from the body and nothing is

        inferred from the request context.
      required:
        - web_identity_token
        - role_id
        - account_id
      properties:
        web_identity_token:
          type: string
          description: >
            The identity token to exchange, as a signed JWT. It is verified
            before

            any role is read: the signature must chain to a key the trusted

            provider publishes, the audience must be the one this platform was

            configured to accept, and `exp` must be in the future.
          example: eyJhbGciOiJSUzI1NiIsImtpZCI6...
        role_id:
          type: string
          format: uuid
          description: >
            The role to assume. Its trust policy has to admit this token — see
            the

            operation description.
          example: 7c9e6679-7425-40de-944b-e07fc1f90ae7
        account_id:
          type: string
          format: uuid
          description: >
            The account the resulting credentials act in — the ownership scope

            stamped on the session, the same scope a signed request selects with

            `X-Account-Id`. Mind the difference in form: the header carries the

            account handle, this field carries the account's id. Naming an
            account

            does not widen the session; the role's own policies remain the
            ceiling.
          example: 550e8400-e29b-41d4-a716-446655440000
        session_name:
          type: string
          description: >
            A label recorded on the session and in the audit trail. Defaults to
            the

            token's `sub` claim, so an unnamed session still records which
            identity

            it came from.
          example: reports-exporter
        duration_seconds:
          type: integer
          minimum: 900
          maximum: 43200
          default: 3600
          description: |
            Credential validity duration (15 min to 12 hours). A value above the
            role's own `max_session_duration` is rejected rather than clamped.
          example: 3600
    AssumeRoleResponse:
      type: object
      properties:
        access_key_id:
          type: string
          example: AKIA...
        secret_access_key:
          type: string
          example: wJalrXUtnFEMI...
        session_token:
          type: string
          example: FwoGZXIvYXdzE...
        expiration:
          type: string
          format: date-time
          example: '2026-01-15T09:30:00Z'
    Error:
      type: object
      required:
        - error
      properties:
        error:
          type: object
          required:
            - code
            - message
            - request_id
          properties:
            code:
              type: string
              description: Error code identifying the type of error
              example: INVALID_INPUT
            message:
              type: string
              description: Human-readable error message
              example: Invalid request parameters
            request_id:
              type: string
              format: uuid
              description: Request ID for debugging
              example: 550e8400-e29b-41d4-a716-446655440000
  responses:
    BadRequest:
      description: Invalid request parameters
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: INVALID_INPUT
              message: Invalid request parameters
              request_id: 550e8400-e29b-41d4-a716-446655440000
    NotFound:
      description: Resource not found
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: NOT_FOUND
              message: Resource not found
              request_id: 550e8400-e29b-41d4-a716-446655440000
    TooManyRequests:
      description: >
        Rate limit exceeded. The budget is a fixed window counted per endpoint
        and

        per caller — the authenticated principal when the request carries

        credentials, the client IP otherwise — so one throttled endpoint never

        spends another's budget, and one tenant never spends another's.


        Wait `Retry-After` seconds, then retry. The `X-RateLimit-*` headers ride
        on

        the successful responses of a rate-limited endpoint too, so a client can

        pace itself instead of discovering the ceiling by hitting it.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: RATE_LIMITED
              message: Too many requests, please try again later
              request_id: 550e8400-e29b-41d4-a716-446655440000
      headers:
        Retry-After:
          description: Seconds to wait before retrying. Never zero.
          required: true
          schema:
            type: integer
            minimum: 1
          example: 42
        X-RateLimit-Limit:
          description: Requests allowed per window on this endpoint.
          required: true
          schema:
            type: integer
            minimum: 1
          example: 5
        X-RateLimit-Remaining:
          description: Requests left in the current window. Always 0 on a 429.
          required: true
          schema:
            type: integer
            minimum: 0
          example: 0
        X-RateLimit-Reset:
          description: >-
            Seconds until the window resets — a duration, not a timestamp, so it
            needs no clock agreement between client and server.
          required: true
          schema:
            type: integer
            minimum: 1
          example: 42
    InternalServerError:
      description: Internal server error
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: INTERNAL_ERROR
              message: An internal error occurred
              request_id: 550e8400-e29b-41d4-a716-446655440000
  securitySchemes:
    SignatureAuth:
      type: apiKey
      in: header
      name: Authorization
      description: >
        Request signing with an access key issued to a service account. An

        HMAC-SHA256 over a canonical form of the request, close to AWS SigV4.

        The `basaltic` CLI signs for you.


        Send `Authorization`, `X-Date` (UTC, `YYYYMMDDTHHMMSSZ`) and `X-Nonce`

        (random per request); add `X-Content-Sha256` to bind a body, and

        `X-Amz-Security-Token` when using temporary credentials.


        ```

        Authorization: BASALTIC-HMAC-SHA256
        Credential=<access_key_id>/<date>/<region>/basaltic/basaltic_request,
        SignedHeaders=host;x-date;x-nonce, Signature=<hex>

        ```


        `<region>` is the region code you are calling, or `global` for the
        global

        services. A signature is valid for 5 minutes from `X-Date`, and mutating

        requests are replay-guarded on the nonce.


        **Full signing procedure, including a working implementation:**

        https://docs.basaltic.sh/authentication


        ## Rate limits

        There is no global request budget. A limit applies only where an

        operation documents a `429`, and that operation says what it counts.

        Those responses carry `X-RateLimit-Limit`, `X-RateLimit-Remaining`,

        `X-RateLimit-Reset` and, on a `429`, `Retry-After` — read them rather

        than hard-coding a number. Retrying before `Retry-After` is refused and

        extends the window. Everything else is bounded by quota, not by request

        rate.

````