A persona is a password-less Bio-ID user in an org you already belong to. You create one with the CLI and get a logged-in browser session as it — without anybody typing a password, and without the access token ever reaching your terminal.
tawa persona create portal-editor --org insureco --name "Portal Editor" --roles editor
tawa persona session portal-editor --service portal --out .tawa/session.json
The second command writes a Playwright storageState file and prints the path. Hand that path
to Playwright, to the browser tooling, or to iec-test — nothing has to read the credential.
const context = await browser.newContext({ storageState: '.tawa/session.json' })
This exists because everything that drives a Tawa app as a logged-in user — a
tawa walkthrough, a Playwright run, a coding agent checking its own work — needed either a
human at a keyboard or a service token, and a service token carries no user, no org and no
roles (and is correctly refused by any service that pins aud to its own client).
isPersona, that can never authenticate interactively.
No password, no login form, no magic link, no reset, no invite. The only way to hold a token
for one is to have it minted.persona+<slug>@<orgSlug>.personas.invalid (.invalid is reserved by RFC 2606). Relay
refuses it.You can only give a persona roles you hold yourself in that org. That is the whole authorization model, and it is what makes "any member may create one" safe: a persona is a token-shaped hole, so the only question that matters is whether the person making it could have had those powers themselves.
| Who may create one | Any active member of the org. No special role. |
| Which roles they may grant | The roles they hold in that org — or, if they can manage users there, any role the org allows. |
| Which modules | The org's own enabled modules, or the creator's. Even for an org admin — granting a module the organization was never given would manufacture an entitlement. |
Platform roles (super_admin, anything platform-tier) | Never, by anyone, including an org admin. There is no branch in which this becomes possible. |
The rule is re-run on update, on the values the persona would end up with — otherwise it
would be decorative (make a viewer persona, then widen it).
A refusal is never a bare 403. Every one names exactly what was refused, exactly what you can grant, and how to get the rest:
✗ You cannot grant a persona role(s) you do not hold yourself in 'acme': agency_admin.
Refused role: agency_admin
Roles you can grant: agent, viewer
Ask an admin of 'acme' — someone who can manage users there may grant any
role assignable in the organization. Platform-level roles (super_admin and
anything else catalogued as platform tier) are never grantable to a persona,
by anyone.
Naming an org you are not in answers the same way, by listing the orgs you are in — because "403" is indistinguishable from a typo in a slug.
With --json, the API's structured error is passed through unchanged, so an agent can read
error.details.grantable and retry with something it is allowed to ask for:
{ "success": false,
"error": { "code": "ROLE_ESCALATION",
"message": "…",
"details": { "refused": ["agency_admin"],
"grantable": ["agent", "viewer"],
"howToGetTheRest": "Ask an admin of 'acme' — …" } } }
Every refusal exits non-zero.
A minted token lives 3 hours and there is no refresh token — none is issued, and no code path can produce one. Re-mint freely; do not try to extend one.
This matters because tokens are verified offline against the JWKS, so a minted token cannot be revoked before it expires. Deleting or disabling a persona stops the next mint and nothing more. Three hours is the accepted exposure, and it is only acceptable because nothing can extend it — in particular, a persona cannot mint, for itself or anyone else, or a 3-hour token would renew itself forever.
The token carries two extra claims so a service can react, though it needs to do nothing:
| Claim | |
|---|---|
persona: true | This is a test session. Show a banner, block destructive actions, exclude it from analytics — your choice. |
act: { by: … } | Who minted it. |
Everything else — sub, bioId, email, orgSlug, roles, modules, branding — is exactly
what a real login produces, from the same claim builder. That is the point: no service changes
anything to accept a persona.
There is no sandbox in use, so a persona that clicks things writes real data. Two conventions follow:
Services that care can enforce read-only on the persona: true claim.
tawa persona create <slug> --org <org> --name "Portal Editor" [--roles a,b] [--modules x,y]
tawa persona list [--org <org>] # default org: the one on your own token
tawa persona show <slug> --org <org>
tawa persona update <slug> --org <org> [--name] [--roles] [--modules] [--disable|--enable]
tawa persona delete <slug> --org <org> --yes
tawa persona session <slug> --org <org> --service <svc> [--env prod] [--out …] [--print]
All of them take --json. Authentication is your own Bio-ID token from tawa login — the same
one the rest of the CLI uses. --org defaults to the org on that token.
tawa login is an OAuth login through the tawa-cli public client (iec-bio#153): it opens
Bio-ID in your browser and finishes on a loopback redirect, PKCE, no secret. On a machine with
no browser — SSH, CI, no TTY — it switches to the device flow on its own and says so: open
https://bio.tawa.pro/oauth/device, type the code it prints. --device forces that; --password
is the old email-and-password login, kept as a fallback and deprecated. Explicit credentials
(--password <value>, TAWA_EMAIL/TAWA_PASSWORD, ~/.tawa/credentials) still select the
password flow, so an unattended runner never waits on a browser.
Only an OAuth login can be exchanged for a service-audience token (tawa session, below). A
password login cannot: Bio-ID refuses to exchange it.
--roles and --modules replace on update, they do not merge.
delete requires --yes. A token already minted for that persona keeps working until it
expires — deleting stops the next mint, not the current one.
tawa persona session does three things:
GET /services/:service/session-info?env=<env> → the OAuth client id, the cookie name
the service reads, and the URL/host the cookie is scoped to. Nothing is guessed.audience = that client id, so it passes the service's aud check
exactly as a real login's would.storageState:{ "cookies": [ { "name": "portal_access_token",
"value": "<the token>",
"domain": "portal.tawa.pro",
"path": "/", "httpOnly": true, "secure": true,
"sameSite": "Lax", "expires": 1726858400 } ],
"origins": [] }
The file is written at mode 0600, its directory is added to .gitignore if nothing already
covers it, and the command says so. Default path: .tawa/session.json.
The token is never printed. Not on stdout, not on stderr, not in a log line, not in an error
message, not in --json. What you get is the persona, org, service, URL, cookie name, expiry
time and the file path.
--print is the exception, for curl. It puts the token alone on stdout —
TOKEN=$(tawa persona session … --print) — and warns on stderr. Use it knowing the value lands
in your shell history and your scrollback.
A persona is for a test identity. When you want to drive a service as yourself — to check a screen you can already see, or run an admin flow — use your own login:
tawa session --service iec-split-pay # → .tawa/session.json
tawa session --service portal --env sandbox --out .tawa/portal.json
It writes the same Playwright storageState as tawa persona session (mode 0600, directory
gitignored, the token never printed), but the cookie holds your token, exchanged at Bio-ID for
one whose audience is the service's own OAuth client (RFC 8693 token exchange, public client
tawa-cli, no secret — iec-bio#155). It lasts 24 hours, capped at your login's own expiry,
and carries act.sub: tawa-cli so a service can tell it came through the CLI.
Bio-ID authorizes on the person, not on a grant table: you must be an active member of the org
that owns the service, or hold super_admin on your membership in the platform org
(super_admin is an org-membership role, not a user attribute). Otherwise:
You are not a member of `<org>`, which owns `<svc>`. Ask an admin of that org, or use a persona.
Requires an OAuth login. A --password login stores a token Bio-ID will not exchange:
Your login predates OAuth login. Run \tawa login` again.`
tawa persona session | tawa session | |
|---|---|---|
| Session is | a test persona | you |
| Lifetime | 3 hours | 24 hours |
| Roles | the persona's | yours in that org |
| Can write | yes | yes, as you — everything targets prod |
| Needs | membership in the persona's org | membership in the service's owning org, or platform super_admin |
--print puts the token alone on stdout (for curl) and warns on stderr; --json reports the file
path, never the token. Like a persona token, an exchanged token cannot be revoked before it
expires, so prefer the session file and re-run rather than sharing one.
A walkthrough spec names an auth profile, never a token. iec-test resolves a persona
profile by name:
"portal-editor": { "kind": "persona", "org": "insureco", "persona": "portal-editor" }
# the spec, unchanged in shape
auth: portal-editor
steps:
- path: /collections
expect: { h1: "Collections", "table tbody tr": "ACME" }
The audience and cookie name are derived from the service under test, so the same profile works
in every environment and the spec stays secret-free. This is the successor to the staff
profile for services that have moved; staff still works for those that have not.
Deploy-time runs use
iec-test's own client, which mints with thepersonas:mintscope rather than a person's token. A platform admin grants that scope to a client; a service token can mint but can never create, change or delete a persona.
# ❌ mint a token per request — it lasts three hours
tawa persona session editor --service portal # …in a loop
# ❌ --print into a file you then commit
tawa persona session editor --service portal --print > token.txt
# ❌ give a persona in a real org write-level roles and let it click things
tawa persona create tester --org acme --roles agency_admin
# ❌ expect deleting a persona to kill a live session
# It stops the NEXT mint. The current token runs out its three hours.
# ✅ one session, reused, with view-level roles
tawa persona create portal-viewer --org insureco --name "Portal Viewer" --roles viewer
tawa persona session portal-viewer --service portal
# → .tawa/session.json, 0600, gitignored, no token on screen
auth is a profile name.Last updated: October 6, 2026