Authentication & cookies

The auth block in a .aether flow: basic, bearer, API key, OAuth2 client-credentials and password grants, AWS SigV4, plus the automatic cookie jar.

6 min read

Most real APIs need a credential. You can always write an Authorization header by hand, and for a static token that is fine. The auth block exists for the cases where hand-writing gets ugly: base64 you would rather not compute, a token that has to be fetched and refreshed, or a signature over the request body.

Sessions are handled separately and need no configuration at all. See the cookie jar below.

Where the block goes

auth sits either on one node’s config, or at the top level of the file as a default for every request node.

version: "1.0"
name: Account API

auth: # the default for every request node in this flow
  type: bearer
  token: ${{ env.API_TOKEN }}

nodes:
  - id: get_me
    type: request
    config:
      method: GET
      url: ${{ variables.baseUrl }}/me
    next: rotate_key

  - id: rotate_key
    type: request
    config:
      method: POST
      url: ${{ variables.baseUrl }}/keys/rotate
      auth: # this node only, replacing the flow default
        type: basic
        username: ${{ env.ADMIN_USER }}
        password: ${{ env.ADMIN_PASSWORD }}
    next: null

A node’s auth replaces the flow default. It does not merge with it. That is deliberate: “what credential does this request send” should be answerable by reading one block, never by mentally combining two that partly overlap.

To opt a single request out of a flow-level default, give it type: none rather than deleting the key. An absent auth inherits; an explicit none does not.

Every string field goes through ${{ }} interpolation, so credentials belong in .env rather than in the flow file you commit.

The six types

basic

auth:
  type: basic
  username: ${{ env.API_USER }}
  password: ${{ env.API_PASSWORD }}

Sends Authorization: Basic <base64(username:password)>.

bearer

auth:
  type: bearer
  token: ${{ env.API_TOKEN }}

Sends Authorization: Bearer <token>.

apikey

auth:
  type: apikey
  key: X-Api-Key
  value: ${{ env.API_KEY }}
  location: header # header | query

location: header sends key: value as a header. location: query appends ?key=value, merged with whatever query string the node’s own queryParams produced.

oauth2

The one type that makes a network call of its own. Two grants are supported.

auth:
  type: oauth2
  grantType: client_credentials
  tokenUrl: https://auth.example.com/oauth/token
  clientId: ${{ env.OAUTH_CLIENT_ID }}
  clientSecret: ${{ env.OAUTH_CLIENT_SECRET }}
  scope: read:users # optional
auth:
  type: oauth2
  grantType: password
  tokenUrl: https://auth.example.com/oauth/token
  clientId: ${{ env.OAUTH_CLIENT_ID }}
  username: ${{ env.OAUTH_USERNAME }}
  password: ${{ env.OAUTH_PASSWORD }}

Bandura posts the standard form-encoded body to tokenUrl and expects a standard { access_token, expires_in } response. A non-2xx reply, or one with no access_token, fails the node and shows you the token endpoint’s own status and body, so you are debugging the auth server rather than guessing at a 401 further down the flow.

The token is fetched once, not once per request. It is cached for the run, keyed by the token endpoint plus client, grant and scope, and reused by every node that shares that key. Ten request nodes behind one oauth2 block make one token call. The cache refreshes shortly before the token’s stated expires_in, with a margin so an in-flight request cannot race an expiring token. expires_in is read whether the server sends it as a number or as a string, since both are common in the wild.

A token whose expires_in is missing or unusable is trusted for 60 seconds, not for the rest of the run. RFC 6749 makes the field only recommended, so a server may legitimately omit it, and caching forever is the failure with no error attached: every request after the real expiry comes back 401, and nothing in the report names auth as the cause. Re-fetching a minute later costs one extra token request and cannot produce a wrong answer.

The cache belongs to one run. A data-driven run fetches a token per row, so nothing leaks between rows. A subflow shares its caller’s cache, because it runs on the caller’s context: the same reason variables cross that boundary. That is what makes authenticate-in-the-parent, call-the-API-in-the-subflow work without a second token request.

There is no authorization-code or PKCE grant yet. That one needs a system browser and a callback listener, which is a different kind of feature from a block in a file.

aws-sigv4

auth:
  type: aws-sigv4
  accessKeyId: ${{ env.AWS_ACCESS_KEY_ID }}
  secretAccessKey: ${{ env.AWS_SECRET_ACCESS_KEY }}
  region: us-east-1
  service: execute-api
  sessionToken: ${{ env.AWS_SESSION_TOKEN }} # optional, for STS credentials

Signs the request with AWS Signature Version 4 and attaches Authorization, X-Amz-Date, X-Amz-Content-Sha256, and X-Amz-Security-Token when a session token is present.

Every x-amz-* header on the request is signed, along with host. That matters because several AWS services will not accept a request otherwise: DynamoDB needs X-Amz-Target, and S3 needs x-amz-content-sha256, both of which the signature has to cover. Set them like any other header and the signer includes them:

headers:
  X-Amz-Target: DynamoDB_20120810.GetItem

The payload hash is computed over the bytes that actually ship, including a multipart or binary body, so the signature matches what the server receives. x-amz-date, x-amz-content-sha256 and x-amz-security-token are always taken from the values this signer is about to send, overriding anything already on the request: signing a stale date, or a hash for bytes the request will not carry, is exactly the mismatch that produces a SignatureDoesNotMatch with nothing to point at.

none

auth:
  type: none

The explicit opt-out described above.

When auth is applied

Ordering matters here, because a signature covers bytes.

  1. A before hook runs and may rewrite the URL, headers, or body.
  2. auth resolves against whatever the hook left behind.
  3. Cookies from the jar are attached.
  4. The request goes out.

So a SigV4 signature always covers the request that actually ships, not an earlier draft of it. Under retry, auth is recomputed per attempt, which is what you want: a signature has an age, and a retry two minutes later needs a fresh timestamp. The OAuth2 cache means that recomputation is normally a lookup, not another token call.

bearer, basic, apikey and oauth2 only ever write a header, or a query parameter for apikey in query mode. None of them touch the body, so they compose cleanly with a graphql or sse block on the same node.

Cookies are automatic. There is no block to write.

When a response carries Set-Cookie, Bandura stores it. When a later request in the same run goes to a URL that cookie’s scope covers, it is sent back as a Cookie header. That makes a log-in-then-use-the-session flow just two nodes:

nodes:
  - id: login
    type: request
    config:
      method: POST
      url: ${{ variables.baseUrl }}/login
      body: "${{ JSON.stringify({ username: env.USER, password: env.PASSWORD }) }}"
      # The Set-Cookie in the response is captured. Nothing to configure.
    next: get_profile

  - id: get_profile
    type: request
    config:
      method: GET
      url: ${{ variables.baseUrl }}/me
      # No Cookie header here. The jar attaches the session cookie itself.
    next: null

What the jar respects:

  • Domain, when present, matches that domain and its subdomains. When absent the cookie is host-only and matches only the exact host that set it.
  • Path, when present, matches that path and anything under it. When absent it defaults to the response URL’s directory.
  • Secure cookies are only ever replayed onto an https: URL.
  • Expires and Max-Age, with Max-Age winning when both appear. An already-expired cookie, including the Max-Age: 0 a server sends to clear one, deletes the stored entry instead of being saved. A cookie with neither attribute lasts the rest of the run.
  • HttpOnly and SameSite are read and ignored. They are instructions to a browser, and no browser is involved here.

An explicit Cookie header always wins. The jar merges into it rather than replacing it, so overriding one cookie by hand does not cost you the rest of the session.

Opting a node out. Set cookies: false on a request node’s config and it neither stores Set-Cookie from its own response nor sends anything from the jar:

- id: public_ping
  type: request
  config:
    method: GET
    url: ${{ variables.baseUrl }}/health
    cookies: false # a health check should not carry the flow's session
  next: null

Like the OAuth2 token cache, the jar belongs to one run, and each row of a data-driven run gets its own. A subflow shares its caller’s jar, for the same reason it shares the token cache: it runs on the caller’s context. Log in in the parent flow and the subflow is already carrying the session.

One run has exactly one jar, so switching between two sessions inside a single flow is not supported yet.

Last updated

Looking for something else? All 37 articles are on one page in the Help Center.