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.
- A
beforehook runs and may rewrite the URL, headers, or body. authresolves against whatever the hook left behind.- Cookies from the jar are attached.
- 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.
The cookie jar: log in once
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.Securecookies are only ever replayed onto anhttps:URL.ExpiresandMax-Age, withMax-Agewinning when both appear. An already-expired cookie, including theMax-Age: 0a server sends to clear one, deletes the stored entry instead of being saved. A cookie with neither attribute lasts the rest of the run.HttpOnlyandSameSiteare 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.
What to read next
- Variables & environments for keeping the credentials themselves out of Git.
- Node reference for the request node’s other config fields.
- Client certificates & proxies when the gateway wants a certificate rather than a token, or the request has to go through a proxy.
- Dynamic values for signing a request by hand with
$hmac. - The .aether file for how the top-level blocks fit together.