Authenticated flows
Sign in with a password and a one-time code, save the session, and let the next job start already signed in.
This guide builds a program that signs in to a site protected by a password and a one-time code: the credentials come from secret inputs, the job parks to ask you for the code, and the signed-in state is saved as a session. The next job with that session skips the login entirely, so you are asked for a code once, not on every run.
#When to use it
Use this pattern when you (your backend, your script, an operator) can supply the one-time code: you read it from an authenticator, a shared mailbox, or an SMS gateway, and answer through the API or the SDK.
When the code arrives on someone else's phone, such as your customer's, do not relay it through your own systems. Declare the input as hosted instead and send that person a link to a page where they type it themselves: see Hosted resolve pages. The program is the same apart from one key.
If a site needs no second factor, you still want the session part of this guide: sign in once, save the session, and stop sending the password on every job.
#The building blocks
#Secret inputs
Declare the password as a secret input. Its value is given per job and never appears in the
job's meta, in logs, in snapshots, in screenshots or in webhooks; a field the value was filled
into is redacted.
"inputs": {
"email": { "type": "string", "default": "[email protected]" },
"password": { "type": "secret" }
}
A secret can be filled into a field, and nothing else: lint refuses a program that reads a secret
in an extract or renders it into a URL (E013).
// `password` is declared `{ "type": "secret" }` in the program: it is encrypted at rest, never
// returned, and redacted from logs, step records and snapshots.
const job = await kaab.jobs.create({
program: name,
inputs: {
base_url: `${site}`,
password: process.env.LOGIN_PASSWORD ?? "secret",
},
});
# password is declared {"type": "secret"} in the program: encrypted at rest, never returned,
# and redacted from logs, step records and snapshots.
password = os.environ.get("LOGIN_PASSWORD", "secret")
job = kaab.jobs.create(program=name, inputs={"base_url": f"{site}", "password": password})
// password is declared {"type": "secret"} in the program: encrypted at rest, never returned,
// and redacted from logs, step records and snapshots.
password := os.Getenv("LOGIN_PASSWORD")
if password == "" {
password = "secret"
}
body, err := kaab.Body[client.CreateJobJSONBody](map[string]any{
"program": name,
"inputs": map[string]any{"base_url": site + "", "password": password},
})
if err != nil {
log.Fatal(err)
}
job, err := c.Jobs.Create(ctx, body)
if err != nil {
log.Fatal(err)
}
#Parking for the code
An await_input step stops the job and asks for a named value:
{ "await_input": { "name": "otp", "prompt": "The code from your authenticator", "type": "string",
"pattern": "^[0-9]{6}$", "ttl": "5m", "expires": "24h", "collect": "api" } }
The job's status becomes awaiting_input, the job.awaiting_input event is sent, and the
envelope carries an input_request with the name, the prompt, the pattern, a screenshot of the
page and two deadlines:
ttl(default 5 minutes, at most 15) is how long the browser stays open on the page. Answer inside it and the run carries on in that same page.expires(at most 7 days) is how long the job waits for the value at all. Answer afterttlbut beforeexpiresand the job restores its last checkpoint and continues; pastexpiresthe answer is refused with410 input_expired.
The value you send lands in vars.otp, which the next step fills in with {{vars.otp}}.
#Answering
Post the value to POST /v1/jobs/{id}/input, keyed by the input's name:
{ "otp": "482913" }
The job answers 200 and is running again, or queued when its browser was already released.
409 job_not_waiting means the job is not parked on an input any more.
With an SDK, jobs.wait does this for you: give it an onInput handler, and whenever the job parks
on an input, the handler's return value is posted and the wait continues to the end.
await kaab.programs.push({ ...definition, name }, { publish: true });
// `password` is declared `{ "type": "secret" }` in the program: it is encrypted at rest, never
// returned, and redacted from logs, step records and snapshots.
const job = await kaab.jobs.create({
program: name,
inputs: {
base_url: `${site}`,
password: process.env.LOGIN_PASSWORD ?? "secret",
},
});
// The job parks at the code; `onInput` answers it and the wait goes on.
const done = await kaab.jobs.wait(job.id, {
timeoutMs: 300_000,
onInput: async (request) => {
console.log(`${job.id} asks for ${request.name}`);
return askForCode(request.prompt ?? request.name);
},
});
console.log(`${done.id}: ${done.status}; session saved: ${done.meta.session?.saved === true}`);
# password is declared {"type": "secret"} in the program: encrypted at rest, never returned,
# and redacted from logs, step records and snapshots.
password = os.environ.get("LOGIN_PASSWORD", "secret")
job = kaab.jobs.create(program=name, inputs={"base_url": f"{site}", "password": password})
# The job parks at the code; on_input answers it and the wait goes on.
done = kaab.jobs.wait(job.id, timeout=300, on_input=ask_for_code)
name := fmt.Sprintf("login-2fa-example-%x", time.Now().UnixMilli())
publish := true
if _, err := c.Programs.Push(ctx, definition, kaab.PushOptions{Name: name, Publish: &publish}); err != nil {
log.Fatal(err)
}
defer func() { _ = c.Programs.Delete(ctx, name) }()
// password is declared {"type": "secret"} in the program: encrypted at rest, never returned,
// and redacted from logs, step records and snapshots.
password := os.Getenv("LOGIN_PASSWORD")
if password == "" {
password = "secret"
}
body, err := kaab.Body[client.CreateJobJSONBody](map[string]any{
"program": name,
"inputs": map[string]any{"base_url": site + "", "password": password},
})
if err != nil {
log.Fatal(err)
}
job, err := c.Jobs.Create(ctx, body)
if err != nil {
log.Fatal(err)
}
// The job parks at the code; OnInput answers it and the wait goes on.
done, err := c.Jobs.Wait(ctx, job.ID, kaab.WaitOptions{Timeout: 5 * time.Minute, OnInput: askForCode})
if err != nil {
log.Fatal(err)
}
if done.Status != "succeeded" {
log.Fatalf("job %s is %s", done.ID, done.Status)
}
fmt.Printf("%s: signed in\n", done.ID)
#Saving the session
A checkpoint with save_session writes the browser's signed-in state to a named
session:
{ "checkpoint": { "name": "logged_in", "save_session": "portal-login" } }
Bind the same session in the program's options and let the program start after that checkpoint whenever the session is still valid:
"options": { "max_tier": "browser", "session": "portal-login", "resume_from_session": true }
Before each job, Kaabist checks the session against the program's logged_in and login_page
landmarks. If the session is valid, the job starts at the step after the checkpoint, and the login
steps, the await_input included, never run. If it has expired, the program runs from the top and
signs in again; policies.on_session_expired (default refresh) decides what happens when a
session expires in the middle of a run.
#Worked example
Take a customer portal at https://portal.example.com that asks for an e-mail address and a
password, then a six-digit code, then shows a report of 40 rows. The program below signs in, asks for the code only when the code form is
actually shown, saves the session, and reads the report:
{
"kaab": "1",
"name": "portal-report",
"inputs": {
"base_url": { "type": "string", "default": "https://portal.example.com" },
"email": { "type": "string", "default": "[email protected]" },
"password": { "type": "secret" },
"report_id": { "type": "string", "default": "1", "pattern": "^[0-9]+$" }
},
"options": { "max_tier": "browser", "session": "portal-login", "resume_from_session": true },
"landmarks": {
"login_page": { "visible": "form[action$='/login']" },
"logged_in": { "url": "**/dashboard" }
},
"steps": [
{ "id": "open", "goto": "{{inputs.base_url}}/login", "expect": { "landmark": "login_page" } },
{ "id": "email", "fill": "input[name=email]", "value": "{{inputs.email}}" },
{ "id": "password", "fill": "input[name=password]", "value": "{{inputs.password}}" },
{
"id": "submit", "click": "form button[type=submit]", "side_effect": true,
"expect": { "any": [{ "visible": "input[name=code]" }, { "landmark": "logged_in" }] }
},
{
"id": "second-factor",
"when": { "visible": "input[name=code]" },
"do": [
{ "id": "ask-otp", "await_input": { "name": "otp", "prompt": "The code from your authenticator", "type": "string",
"pattern": "^[0-9]{6}$", "ttl": "5m", "expires": "24h", "collect": "api" } },
{ "id": "fill-otp", "fill": "input[name=code]", "value": "{{vars.otp}}" },
{ "id": "verify", "click": "form button[type=submit]", "side_effect": true,
"expect": { "landmark": "logged_in" } }
]
},
{ "id": "save", "checkpoint": { "name": "logged_in", "save_session": "portal-login" } },
{
"id": "report", "goto": "{{inputs.base_url}}/reports/{{inputs.report_id}}",
"expect": { "count": { "target": "#report tbody tr", "min": 40 } }
},
{
"id": "rows",
"extract": {
"rows": {
"selector": "#report tbody tr", "many": true, "min_items": 40,
"fields": {
"date": { "selector": "td:nth-child(1)", "type": "date", "date_format": "dd.MM.yyyy" },
"description": "td:nth-child(2)",
"amount": { "selector": "td:nth-child(3)", "type": "money", "locale": "tr", "currency": "TRY" }
}
}
}
}
],
"policies": { "on_session_expired": "refresh", "screenshot": "on_failure" }
}
Run it with the account's password:
- Create the job with
inputs: { "password": "<the account password>" }. It runs the login and parksawaiting_inputwithinput_request.nameset tootp. - Post the code to
POST /v1/jobs/{id}/input, or return it from theonInputhandler as in the snippet above, for example{ "otp": "482913" }. - The job verifies the code, saves the session
portal-loginand reads the report. The envelope showsstatus: "succeeded", 40 rows indata.rowsandmeta.session.saved: true. - Create a second job with the same inputs. Its session is valid, so it starts after the
checkpoint:
meta.stepshas noawait_inputstep, and nobody is asked for a code.
#Pitfalls
- Clicks that sign in are side effects. Mark the submit buttons
side_effect: true, as above, so that a retry never presses them twice; see side-effect safety. - A second factor that only sometimes appears. Wrap the
await_inputin awhen, as the worked example does. An unconditionalawait_inputasks for a code even when the site trusted the device and skipped it. - One session, one job at a time. A session is exclusive by default: a second job that binds it waits until the first one ends. Run parallel work under different sessions.
- The browser window is short.
ttlis at most 15 minutes. A late answer still works, untilexpires, but costs a few seconds while the job restores its checkpoint. - Login programs are rate limited and gated. A program that fills a password field or a secret
input runs at most 60 times an hour per organisation (
429 login_flow_rate_limited), and needs a verified payment method on your organisation. Reusing a session keeps you well under the limit. - Never put a password in
default. A default is stored with the program version; a secret is given per job.
#Reference
await_input,checkpoint,when- Inputs and options (
session,resume_from_session), policies (on_session_expired) - Submit a job's input, get a job
- create a session and the session schema
kaab jobs input- Concepts: sessions, human in the loop