On this page

#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:

ClaimRequiredValue
subyesYour user id. Becomes $auth.subject.
audyesYour workspace slug, exactly.
expyesUnix seconds. Keep it short — Vatio re-checks it on every tool call.
namenoFills the contact's name.
emailnoFills the contact's email.
phone_numbernoFills the contact's phone.
metadatanoWhat 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"
    }
  }
}
KeyTypeShown as
profilestring, http:// or https:// URLA 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.
badgesobject of label → valueOne 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.

Last updated:

Latest CLI version: 0.79.0 · Changelog · The whole contract as Markdown: /docs/docs.md · or vatio docs