#Authentication
You sign a JWT saying who the visitor is. Vatio verifies it and passes it to your tools. That is the whole of authentication — there is one mechanism, on every channel.
Vatio holds only your public key, so it can check a token but never mint one. Your backend stays the only thing that can say who someone is.
Users sign in with Supabase?
Skip the keypair and the signing: vatio auth --supabase names your project,
and the widget, WhatsApp and Instagram use the Supabase session as it is. See
Supabase.
#Set it up
Generate the keypair:
vatio auth --new-key
That writes two files, and they go to different places.
identity.pub stays in the workspace. It is a public key, so it is
committed like any other file and vatio.yml names it:
# vatio.yml
auth:
public_key: identity.pub
identity.pem goes into your own backend, as a secret — an environment
variable, or whatever credential store you already use. Your backend is what
signs, so it is the only thing that ever needs it. Load it, then delete the
file from the workspace directory; it has no job there.
# environment variable
VATIO_IDENTITY_PRIVATE_KEY="$(cat identity.pem)"
# or Rails credentials
bin/rails credentials:edit # vatio: { identity_private_key: "..." }
Never vatio secrets set the private key. That store is read by Vatio, to
let your tools call your API. A private key in there would let Vatio mint
tokens for your users instead of only checking them, which is the one thing
this design exists to prevent. Vatio holds the public half and nothing else.
Mark every tool that needs a signed-in user:
# tools/my_bookings.yml
access: private
Tools without access: private are public and run for anyone.
#The token
Sign it with identity.pem using RS256:
| Claim | Required | Value |
|---|---|---|
sub | yes | Your user id. Becomes $auth.subject. |
aud | yes | Your workspace slug, exactly. |
exp | yes | Unix seconds. Keep it short — Vatio re-checks it on every tool call. |
name | no | Fills the contact's name. |
email | no | Fills the contact's email. |
phone_number | no | Fills the contact's phone. |
metadata | no | What the inbox shows about the person: a profile link and badges. See Enrich the inbox. |
Any other claim you add arrives as $auth.claims.<name>.
# Ruby
JWT.encode(
{ sub: user.id.to_s, aud: "acme", exp: 1.hour.from_now.to_i,
name: user.name, email: user.email },
OpenSSL::PKey::RSA.new(ENV["VATIO_IDENTITY_PRIVATE_KEY"]), "RS256"
)
// Node (jsonwebtoken)
jwt.sign(
{ sub: String(user.id), name: user.name, email: user.email },
process.env.VATIO_IDENTITY_PRIVATE_KEY,
{ algorithm: "RS256", audience: "acme", expiresIn: "1h" }
);
# Python (PyJWT)
jwt.encode(
{"sub": str(user.id), "aud": "acme",
"exp": int(time.time()) + 3600, "name": user.name},
os.environ["VATIO_IDENTITY_PRIVATE_KEY"], algorithm="RS256",
)
aud is required because a signature proves who signed, not what for.
Without it, any other JWT you sign with the same key — a password reset, a
download link — would be accepted here as an identity.
#Enrich the inbox
Put a metadata object in the token and the inbox shows it in the
conversation header, so whoever answers knows who they are talking to without
opening your admin:
{
"sub": "42",
"aud": "acme",
"exp": 1790000000,
"name": "Claudio Andrés",
"metadata": {
"profile": "https://acme.com/admin/users/42",
"badges": {
"plan": { "value": "Premium", "tone": "green" },
"payment": { "value": "Overdue", "tone": "red" },
"country": "CL"
}
}
}
| Key | Type | Shown as |
|---|---|---|
profile | string, http:// or https:// URL | A Profile ↗ link that opens in a new tab. Anything else shows a quiet "Invalid profile link" warning instead, with the value you sent in its tooltip. |
badges | object of label → value | One chip per entry, plan: Premium, in the order you send them. A value is a string, number or boolean, or { "value": ..., "tone": ... } to colour the chip. Up to 10, each label and value cut at 60 characters. |
tone is one of green, amber, red or gray. Leave it out, or send
anything else, and the chip takes your inbox's accent colour.
Both are optional, and any other key under metadata is ignored by the inbox.
The inbox reads it from the latest token for that conversation, so a
refreshed token updates the header. Tools see the whole object as
$auth.claims.metadata.
// Node (jsonwebtoken)
jwt.sign(
{
sub: String(user.id),
name: user.name,
email: user.email,
metadata: {
profile: `https://acme.com/admin/users/${user.id}`,
badges: {
plan: { value: user.plan, tone: user.paying ? "green" : "gray" },
country: user.country,
},
},
},
process.env.VATIO_IDENTITY_PRIVATE_KEY,
{ algorithm: "RS256", audience: "acme", expiresIn: "1h" }
);
Next: how the token reaches Vatio on each channel, and what a private tool sees once it does.