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.
Pick a kind
Section titled “Pick a kind”| Your situation | Use |
|---|---|
| Any conformant OAuth 2.0 / OIDC server — you have an issuer URL | oauth2 |
| Keycloak, Clerk, Auth0, Firebase, or Supabase | integration |
| AWS API Gateway, S3, Lambda — anything authenticated by a SigV4 signature | awsSigV4 |
| A login endpoint that returns a token in the body | request |
| Anything else — a handshake, a signed assertion, two calls | script |
| A long-lived key that no token dance produces | No 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.
Where a provider lives
Section titled “Where a provider lives”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/.
Start from the spec
Section titled “Start from the spec”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 spec | Becomes |
|---|---|
oauth2 with an authorizationCode or clientCredentials flow | An oauth2 provider with the flow’s URLs and scopes filled in |
openIdConnect with an openIdConnectUrl | An oauth2 provider whose issuer is that URL minus /.well-known/openid-configuration |
apiKey / http carrying x-amazon-apigateway-authtype: awsSigv4 | An 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.
OAuth 2.0 / OIDC
Section titled “OAuth 2.0 / OIDC”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-stagingkind: oauth2oauth: grant: authorizationCode issuer: https://login.example.com/realms/acme clientId: tinspec-desktop clientSecretRef: BILLING_STAGING.CLIENT_SECRET scopes: [openid, profile, billing.read] pkce: truesecretRefs: - BILLING_STAGING.CLIENT_SECRETThe four grants:
grant | What it does | Needs a human? |
|---|---|---|
authorizationCode | RFC 6749 §4.1 with PKCE, redirecting to a 127.0.0.1 listener | Once, in a browser |
clientCredentials | RFC 6749 §4.4 — machine-to-machine | No |
deviceCode | RFC 8628 — a code you type on another device | Once, as text |
refreshToken | RFC 6749 §6 — exchange a refresh token you already hold | No |
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.1by default, which is what RFC 8252 recommends. If your authorization server demands an exact registeredredirect_uri, setredirectPort(andredirectPath, 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.
When discovery is not enough
Section titled “When discovery is not enough”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.
Senders for a generic provider
Section titled “Senders for a generic provider”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.
AWS SigV4
Section titled “AWS SigV4”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
injectblock 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-gatewaykind: awsSigV4aws: region: eu-central-1 service: execute-api accessKeyIdRef: ORDERS_GATEWAY.AWS_ACCESS_KEY_ID secretAccessKeyRef: ORDERS_GATEWAY.AWS_SECRET_ACCESS_KEYsecretRefs: - ORDERS_GATEWAY.AWS_ACCESS_KEY_ID - ORDERS_GATEWAY.AWS_SECRET_ACCESS_KEYsenders: - id: read-only label: Read-only role vars: roleArn: arn:aws:iam::123456789012:role/ReadOnly sessionName: tinspecThe 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.
Integration
Section titled “Integration”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-apikind: integrationintegration: keycloakconfig: baseUrl: https://sso.example.com realm: acme clientId: test-harness grant: passwordsecretRefs: - users-api.CLIENT_SECRETsenders: - id: admin@example.com label: Staging admin secretRef: users-api.admin.PASSWORDThe 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.
Request
Section titled “Request”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-loginkind: requestrequest: 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_tokenThe request may use any protocol, so fetching a token over gRPC and using it on HTTP calls is ordinary rather than special.
Script
Section titled “Script”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.
A credential nothing mints
Section titled “A credential nothing mints”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:
| Scheme | Sent as |
|---|---|
bearer | Authorization: Bearer <value> |
basic | Authorization: Basic base64(username:<value>) |
apiKey | The 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_KEYThe 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.
Say where the credential goes
Section titled “Say where the credential goes”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:
target | name means | Notes |
|---|---|---|
header | Header name | The default |
query | Parameter name | The bare token, no scheme prefix |
cookie | Cookie name | Appended as name=value to the Cookie header |
body | A JSON dot-path | Set 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.
Overriding it for one request
Section titled “Overriding it for one request”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.
Add senders
Section titled “Add senders”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.comThis is how you check an authorization rule: send the same endpoint as an admin and as a viewer and compare the statuses.
Secrets are references
Section titled “Secrets are references”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.
Use it
Section titled “Use it”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-apiChains inherit it too — a chain step that references a request carries that request’s auth.
Check it works
Section titled “Check it works”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.
When a credential is rejected
Section titled “When a credential is rejected”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.
Troubleshooting
Section titled “Troubleshooting”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.