Skip to content

Automate auth

The goal is that no request ever carries a pasted token. Set a provider up once and every request that references it authenticates itself.

Background: Auth provider and Sender.

Your situationUse
Any conformant OAuth 2.0 / OIDC server — you have an issuer URLoauth2
Keycloak, Clerk, Auth0, Firebase, or Supabaseintegration
AWS API Gateway, S3, Lambda — anything authenticated by a SigV4 signatureawsSigV4
A login endpoint that returns a token in the bodyrequest
Anything else — a handshake, a signed assertion, two callsscript
A long-lived key that no token dance producesNo provider — put it on the request

A provider’s kind — and, for an integration, which identity provider — is chosen once, when the provider is created, and is read-only afterwards. There is no convert; make a new one.

One provider per file, .tinspec/auth/<name>.yaml in your project folder, committed like everything else. A script provider has a sibling .tinspec/auth/<name>.js. Every YAML snippet below is the contents of one such file.

The app reads providers from that folder. The collection format also allows an authProviders list inside a collection file, but the desktop app does not load one from there — keep them in .tinspec/auth/.

If an OpenAPI source is already bound, the document has usually already said how the API expects to be authenticated: the grant, the token URL, the authorization URL, the scopes. Add auth provider → From your API spec reads components.securitySchemes and offers each scheme pre-filled. You supply the one thing a document cannot carry — the client secret — and that is stored as a keychain reference.

What maps to what:

Declared in the specBecomes
oauth2 with an authorizationCode or clientCredentials flowAn oauth2 provider with the flow’s URLs and scopes filled in
openIdConnect with an openIdConnectUrlAn oauth2 provider whose issuer is that URL minus /.well-known/openid-configuration
apiKey / http carrying x-amazon-apigateway-authtype: awsSigv4An awsSigV4 provider, service execute-api — the region is asked for, never guessed

Schemes that are not providers are listed too, with the reason and where to go instead, rather than being dropped silently: http bearer, http basic and apiKey are credentials, not ways to obtain one, so they belong on the request; mutualTLS is transport configuration and lives in the API provider’s network settings; the implicit and resource-owner-password flows are not among the four grants the engine runs.

kind: oauth2 is the vendor-neutral path: any conformant authorization server, four grants, and endpoints discovered rather than pasted. Set issuer and the engine fetches {issuer}/.well-known/openid-configuration and reads the authorization, token, device and userinfo endpoints from it — at call time, so a rotated endpoint keeps working with no edit here.

name: billing-staging
kind: oauth2
oauth:
grant: authorizationCode
issuer: https://login.example.com/realms/acme
clientId: tinspec-desktop
clientSecretRef: BILLING_STAGING.CLIENT_SECRET
scopes: [openid, profile, billing.read]
pkce: true
secretRefs:
- BILLING_STAGING.CLIENT_SECRET

The four grants:

grantWhat it doesNeeds a human?
authorizationCodeRFC 6749 §4.1 with PKCE, redirecting to a 127.0.0.1 listenerOnce, in a browser
clientCredentialsRFC 6749 §4.4 — machine-to-machineNo
deviceCodeRFC 8628 — a code you type on another deviceOnce, as text
refreshTokenRFC 6749 §6 — exchange a refresh token you already holdNo

clientId is a plain field: it is public by specification. Everything else that authenticates is a reference — clientSecretRef, and refreshTokenRef for the refresh grant.

The browser step is the app’s, not the engine’s

Section titled “The browser step is the app’s, not the engine’s”

The engine never opens a browser, because it has to keep working in CI where there is none. So the authorization-code flow is split at exactly that line: the engine owns the PKCE verifier and challenge, the state nonce and its validation, the loopback listener, the redirect capture, the bounded wait and the code exchange. The desktop app’s only job is opening the URL the engine hands back.

Two consequences worth knowing:

  • The listener binds an ephemeral port on 127.0.0.1 by default, which is what RFC 8252 recommends. If your authorization server demands an exact registered redirect_uri, set redirectPort (and redirectPath, default /callback) so the URI is stable enough to register.
  • A sign-in that is never completed times out after five minutes, and the device grant gives up after ten if the server declares no expiry of its own. The window shows the countdown instead of going quiet.

A failure names the step it failed at — starting the flow, opening the browser, or waiting for the redirect — because a wrong issuer and an unregistered redirect URI look identical from the outside.

authorizationUrl, tokenUrl, deviceAuthorizationUrl and userinfoUrl can each be set explicitly, and an explicit value always wins over a discovered one. They exist for servers with no discovery document, or a wrong one. Pressing Discover clears them, so a stale override cannot keep pointing at an old endpoint.

Other fields that earn their keep: clientAuth (basic | post | none — how the client authenticates at the token endpoint; basic when a secret is set, none otherwise), tokenField (default access_token; set id_token for an API that wants the OIDC identity token), audience for APIs that need one to get a non-opaque token, and extraParams, an open map appended verbatim to the authorization and token requests for the parameters that are your server’s vocabulary rather than ours.

What the refresh token does, and where it stays

Section titled “What the refresh token does, and where it stays”

The interactive grants need a human once; afterwards the refresh token carries them. The engine keeps it in memory only, keyed by provider and sender, and honours rotation when the server returns a new one. It is never written to disk, and it is deliberately not exposed as a variable — a variable is something you can substitute into a URL or a body, and a refresh token has no business being either.

That memory is also why Test behaves differently for this kind: for every other kind it runs on a throwaway engine so it answers “does this configuration work” rather than “is there still a token cached”. For oauth2 it runs on the app’s own engine, because an interactive grant can only complete from the refresh token held by the engine the sign-in ran on — on a fresh one it would report “sign in first” immediately after a successful sign-in, which would be a lie about your configuration.

An oauth2 provider can list its identities like a named integration does: from userinfo (the signed-in subject), or from an admin/directory endpoint you name in oauth.sendersUrl — Clerk’s /v1/users, Keycloak’s /admin/realms/{realm}/users, whatever yours is.

Refreshing that list updates labels and tenants. An id the IdP no longer returns is flagged, not deleted — a test account removed upstream is still your row to decide about — and a sender’s secretRef and vars are never touched by a refresh.

kind: awsSigV4 is the odd one out, and the difference is structural rather than cosmetic: it signs the request instead of producing a token.

A SigV4 signature is a keyed hash over the final request — the method, the canonical path, the canonical query string, a chosen set of headers and a hash of the body. There is no {{token}} you could put in a template that would produce it, because the template’s own text would be part of what gets hashed. So the engine applies it as the very last step of preparation, after variable substitution, after any body injection, after a gateway route rewrite.

Two things follow, and both are visible:

  • There is no injection to configure. An inject block on a SigV4 provider is an error, not a silently ignored field, so the Injection tab is replaced by a line saying why it does not apply rather than sitting there doing nothing.
  • Its Senders tab is called Roles. A sender here is an assume-role target, not a person.
name: orders-gateway
kind: awsSigV4
aws:
region: eu-central-1
service: execute-api
accessKeyIdRef: ORDERS_GATEWAY.AWS_ACCESS_KEY_ID
secretAccessKeyRef: ORDERS_GATEWAY.AWS_SECRET_ACCESS_KEY
secretRefs:
- ORDERS_GATEWAY.AWS_ACCESS_KEY_ID
- ORDERS_GATEWAY.AWS_SECRET_ACCESS_KEY
senders:
- id: read-only
label: Read-only role
vars:
roleArn: arn:aws:iam::123456789012:role/ReadOnly
sessionName: tinspec

The role lives in the sender’s ordinary non-secret vars — roleArn is required, and sessionName, externalId, durationSeconds and a region override are optional. No new format field was needed for any of it. Selecting that sender makes the engine call STS AssumeRole with the provider’s base credentials and sign with the temporary ones, so “as the read-only role, then as admin” is the same dropdown every other provider has. With no roles listed, requests are signed with the base credentials directly.

sessionTokenRef carries a session token for credentials obtained elsewhere (SSO, aws sts get-session-token); stsEndpoint overrides the default https://sts.amazonaws.com for a regional or VPC endpoint. The access key id is a reference even though it is only semi-secret: the pair is useless split, and one rule — credentials are references — is easier to keep than two.

Create the provider, choose the identity provider, and fill in its non-secret config — issuer or base URL, realm, client id, scope, domain. Client secrets and passwords are stored as keychain entries and referenced by name.

keycloak and auth0 take a grant of password or client_credentials. firebase and supabase exchange email and password for a token. A Firebase provider configured with a service account can also list its users, and can mint a token for a user id without that user’s password.

name: users-api
kind: integration
integration: keycloak
config:
baseUrl: https://sso.example.com
realm: acme
clientId: test-harness
grant: password
secretRefs:
- users-api.CLIENT_SECRET
senders:
- id: admin@example.com
label: Staging admin
secretRef: users-api.admin.PASSWORD

The five integrations are keycloak, clerk, auth0, firebase and supabase, and that list is deliberately closed — anything else belongs behind oauth2 rather than a sixth special case.

When the login is just an API call, describe it as one. The provider carries a whole request plus a single extraction saying where the token is.

name: legacy-login
kind: request
request:
name: Login
method: POST
url: "{{baseUrl}}/auth/login"
body:
contentType: application/json
content: '{"user":"svc","pass":"{{pw}}"}'
extract:
var: token
from: body
path: data.access_token

The request may use any protocol, so fetching a token over gRPC and using it on HTTP calls is ordinary rather than special.

For everything else. Your JavaScript runs in the engine’s sandboxed QuickJS runtime with a controlled fetch, the ts.* standard library, and returns { token, expiresIn? }.

async function authenticate({ env, config, secret, fetch, sender }) {
const res = await fetch(`${env.baseUrl}/auth/login`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
username: sender?.id,
password: secret(sender?.secretRef ?? "LOGIN_PASSWORD"),
}),
});
if (!res.ok) throw new Error(`login failed: ${res.status}`);
const json = await res.json();
return { token: json.access_token, expiresIn: json.expires_in };
}

Returning expiresIn is worth doing — it is how the cache knows when to refetch. Full contract, including the ts.* surface for HMACs and signed assertions: Auth script contract.

Some APIs are keyed by a long-lived partner key or a shared staging secret that no token dance produces. There is nothing for a provider to automate, and the old answer — typing Authorization: Bearer eyJ… into the Headers tab — put a literal secret in a committed file.

So a request can carry the credential itself, on its Auth tab, in one of three shapes:

SchemeSent as
bearerAuthorization: Bearer <value>
basicAuthorization: Basic base64(username:<value>)
apiKeyThe value in the header, query parameter or cookie you name (default header X-API-Key)
auth:
inline:
scheme: apiKey
placement: header
name: X-API-Key
valueRef: API_EXAMPLE_COM.API_KEY

The value is still never in the file. InlineAuth has exactly one credential field, valueRef, and it holds a keychain name. The editor’s value input is write-only: what you type goes straight to the secret store and is never written into the request, never read back, never reachable from the collection. The only literal in the shape is username, which identifies rather than authenticates. A missing keychain entry is an error naming it, never a request sent unauthenticated.

An inline API key may go in a header, a query parameter or a cookie. body and variable are refused with a message: a transport credential does not belong buried in a payload, and placing nothing at all would send the request unauthenticated with no explanation for the 401.

Paste a bearer token and it is decoded — payload only, nothing verified — so the panel can tell you how long it has left. If its claims name an OIDC issuer, you are offered a matching oauth2 provider pre-filled from them, which is the point at which automating it costs a minute instead of an afternoon.

Injection is a property of the provider, so it is not repeated on every request:

inject:
target: header
name: Authorization
template: "Bearer {{token}}"

Omit it entirely and you get Authorization: Bearer {{token}}. For an API-key style header, set name: X-API-Key and template: "{{token}}".

Five targets:

targetname meansNotes
headerHeader nameThe default
queryParameter nameThe bare token, no scheme prefix
cookieCookie nameAppended as name=value to the Cookie header
bodyA JSON dot-pathSet inside the JSON body, the gRPC message, or the GraphQL variables
variable—Places nothing; the token is only exposed as {{token}}

A header, query parameter or cookie the request already sets wins over the injected one — the provider supplies a default, not an override. The body target follows the same rule: a value already at the path is kept, and intermediate objects are created when they are missing.

body has two limits, both refused before any network call rather than at 401 time: it needs a JSON body slot, so a multipart or file body is rejected, and a WebSocket request has no body at all, so the target is not even offered there.

variable is the escape hatch for a credential that has to go somewhere none of the others reach. {{token}} resolves anywhere you type it — URL, headers, body, gRPC message, GraphQL variables — so this is placement by hand, not a missing feature.

Most calls want the token in a header and one wants it inside the JSON body. Keep the provider’s default and override that one request from its own Auth tab (Inject as → Custom). The override is saved with the request as auth.inject:

auth:
provider: users-api
inject:
target: body
name: auth.token
template: "{{token}}"

Precedence is: the request’s own inject, else the provider’s, else the header default.

One provider, several identities. Each sender has an id, an optional label and tenant, and — for the providers where each identity carries its own credential — a secretRef naming its own keychain entry.

Whether a sender needs a password of its own depends on the kind: password-grant integrations (Keycloak and Auth0 under grant: password, Firebase, Supabase) sign in per sender and the engine refuses without one; Clerk has one secret key and many senders; an OAuth sender is an identity the IdP already knows; a SigV4 sender is a role ARN.

Mark one as the default. A request can override it:

auth:
provider: users-api
sender: viewer@example.com

This is how you check an authorization rule: send the same endpoint as an admin and as a viewer and compare the statuses.

A provider file holds names. secretRefs is the list of keychain entries that provider is allowed to read, and it is what the app appends to whenever you save a secret through one of its forms. A name not in that list is not handed to the engine at all, which is why a hand-edited secretRef that was never saved through the app reads as missing.

Values go to the OS keychain, or to a machine-local encrypted file when you would rather not be prompted — the choice is offered on each save and defaults from Settings → Security. Neither travels with the file, which is what makes a provider definition safe to commit.

Reference the provider from a request’s Auth tab, or set it once as the collection default so every request in the file inherits it:

defaults:
auth:
provider: users-api

Chains inherit it too — a chain step that references a request carries that request’s auth.

Test on the provider resolves a credential and reports the variables it produced, masked to head and tail (eyJh…s2Qk; anything eight characters or shorter is dots only). Copy token hands you the raw credential from the same cache your requests use, for when you need it in a terminal.

Every other tool refreshes tokens on a timer, which can only ever be a guess — a timer never finds out it was wrong. Tinspec invalidates on the actual response.

If a send comes back 401 — or a 403 that unambiguously says so, via WWW-Authenticate: error="invalid_token" or a known expiry code such as ExpiredToken near the top of the body — the engine drops that provider-and-sender’s cached credential, resolves a fresh one and retries that one send, once. A request with no named provider never retries: there is nothing cached to invalidate.

The response then carries the record, and it is worth reading rather than skipping:

  • It compares the rejected credential’s real age against the lifetime it advertised. A provider whose tokens actually live 55 seconds while claiming an hour looks, without this, like a flaky API.
  • Under 30 seconds of advertised life left, it says clock skew rather than presenting a finding. Near enough to the expiry is not evidence of anything.
  • If the retry failed too, it says the credential was not the problem. The fresh credential got the same status, so the answer is in the request — scope, sender, or the endpoint’s own rules — and sending you to look at token lifetimes would waste the afternoon.

The token is fetched on every send. The provider is not reporting an expiry. Return expiresIn from a script, or check the integration’s config.

secret(...) throws. The keychain entry named in secretRefs or a sender’s secretRef does not exist on this machine. Secrets are per-machine by design and never sync — set it here.

It works for one identity and not another. A sender without a secretRef has no credential. Check that each sender points at its own keychain entry.

Discovery fails. The issuer is the base URL, not the discovery URL: the engine appends /.well-known/openid-configuration itself. If the server genuinely has no discovery document, set the endpoints explicitly — they win over discovery.

A browser sign-in times out. The listener waits five minutes on 127.0.0.1. If the authorization server rejects the redirect before that, it is usually an unregistered redirect_uri — register the exact URI the flow reports, and pin redirectPort so it stops moving.

A SigV4 request is signed but refused. The signature covers the region and service, so a wrong region produces a well-formed signature for the wrong scope. Neither value is in an OpenAPI document, which is why a spec-seeded provider leaves the region blank rather than guessing one.

A provider-level proxy works in the editor and fails in a chain. Layered send settings are not applied inside a chain in this release — only a request’s own settings are. That is a known gap, not a misconfiguration.