Security
Mihe operates your app on your users’ behalf, which is a bigger thing to ask than a chat product asks. This page says what stops it doing something it should not, what it stores and who else sees it. It is written for your security reviewer, and every claim on it is one they can check.
Last updated 29 September 2026
What you trust us with
You hand over a description of your app’s screens and actions, a shared secret we use to verify your user tokens and sign calls back to you, and whatever passes through a conversation, including action results and voice transcripts.
You do not hand over any authority. Mihe cannot decide that a user may do something. Your backend tells it, on every session, in a token you signed, and every permission decision is a comparison against that token’s claims.
The registry is the least sensitive part of this. Screen ids, routes, button labels and action descriptions are a map of a UI every one of your users with a login already sees. The exposure that matters is the secret, the data in flight and the subprocessors, and those are the sections to read closely.
Enforced by the code
These cannot be switched off or misconfigured, because they are how the code is arranged rather than settings.
- The model cannot reach an effect.
- One component decides what may run, and the component that talks to the model cannot import it, nor it the model's. Only the component between them sees both. There is no path from a model's output to an action that skips the policy check, and adding one would not compile.
- What the model is offered and what it may run cannot drift.
- The tools the model is shown and the check an action has to pass come from the same function. An action a user has no permission for is never offered, so the model is not shown something it may not run.
- Identity never comes from the browser.
- The SDK runs in a browser the user controls, so anything it says about itself could be forged. Who the user is and what they may do come only from the verified claims of a token your backend signed. We cap how long that token may live.
- The confirmation card is worded by your registry.
- Every readable field on a confirmation card, its label and its description, is copied from your registry, never from what the model wrote. The fields beneath are exactly the input the action will receive.
- High-risk actions need an on-screen tap.
- In every mode, including voice. A transcribed “yes, go ahead” is a model's interpretation of audio, and not a strong enough signal to move money or delete data.
- A refusal is recorded.
- Every attempted action is written to the audit log with its outcome, including the ones policy stopped. “The assistant tried this and was stopped” is exactly what you should be able to see.
- What you cannot reach looks missing.
- An organisation or app you are not a member of answers 404, never 403, so our API cannot be used to find out who our customers are.
Enforced by your settings
Real controls, but ones you can leave in a weak state.
- Allowed origins, checked when a session opens
- Default: Empty, which allows any origin
- Note: Right before you have configured one. Do not leave it there.
- Rate limits per user, per app and per session
- Default: On
- Note: Applied by us to every app.
- Voice
- Default: Off for every app
- Note: A minute of speech costs about what a hundred typed turns cost.
- Longest voice session
- Default: 10 minutes
- Note: A cost control, not a security boundary.
- Each action's risk tier, permissions and kill switch
- Default: What you set
- Note: Changed from the dashboard without a deploy.
- Where backend actions may send their calls
- Default: Nowhere
- Note: Allow your backend's origins in the dashboard. A publish naming any other origin is refused, and calls stop the moment an origin is removed.
Keys and signatures
An app has four kinds of key
- The public key identifies the app. It is safe in a browser bundle and grants nothing on its own.
- The secret key verifies your user tokens and signs our calls to your backend. It belongs on your server only, and a session opened from a browser with it is refused.
- The server key reads your usage, so your backend can bill its own users, and does nothing else. It exists so that job never needs the secret key, which can mint any identity in your app. It reaches one endpoint, names no app of its own choosing, and is stored only as a hash, so it cannot be recovered, only replaced.
- The publish key lets your build or CI publish the app’s registry, and does nothing else. It cannot read or spend anything. It can rewrite your actions, but it cannot send a backend call anywhere new: a backend action may only call an origin you allowed in the dashboard, which this key cannot change. Stored only as a hash, like the server key.
Secrets are encrypted, with the key kept outside the database
An app’s secret is stored twice: a SHA-256 hash for checking a presented key, and an XChaCha20-Poly1305 ciphertext for the moments we must actually sign with it. The master key lives outside the database, so a copy of the database on its own yields no usable secret. Ciphertexts carry a version, so the scheme can be rotated.
Calls to your backend are signed
HMAC-SHA256 over timestamp.body, with a five-minute replay window and a constant-time comparison. The timestamp is inside the signature, so a captured request cannot be refreshed by editing a header. The scheme is pinned to a test vector you can check your own implementation against. How to verify it.
The sharpest question, answered plainly
When Mihe calls an action on your backend, the acting user arrives as an id inside the signed envelope. We do not yet forward the user’s own token. So your server has no way to verify the user independently. It is trusting our word for who is asking, which means your security depends on ours for the actions you register.
Two things limit that today. Keys are per app and can be revoked. And a correct integration re-derives the user’s permissions from its own database rather than trusting the envelope, which it has to do anyway, because the envelope’s permissions arrive empty by design.
Signing in to the dashboard
The dashboard’s sign-in is our own rather than a hosted provider’s. It covers your staff, never your end users, who never sign in to us.
- Passwords are hashed with argon2id,
- at OWASP's current parameters, with a salt per password. Twelve characters minimum and no composition rules, because length is what buys strength.
- Sessions rotate.
- A fifteen-minute access token and a thirty-day refresh token, stored only as a hash and replaced every time it is used, so a stolen one cannot be spent alongside the real one.
- Two-factor is TOTP,
- optional, with ten single-use recovery codes. A code cannot be accepted twice, and the step between password and code is refused everywhere a full sign-in is accepted.
- Nothing tells an outsider who has an account.
- Sign-up, sign-in and password reset answer the same way, and take the same time, for a known address and an unknown one.
- Links in email are single use.
- Verification, reset and invitation links are random, stored as hashes, expire, and stop working when a password changes or a fresh link is sent. An invitation also has to be redeemed by the invited address.
- Sign-in is rate limited
- by address, by user and by IP. There is deliberately no lockout: slowing guesses is enough, and a lockout would let anyone lock a named person out of their own account.
What we store
- Registry: screens, components, actions
- Stored: Yes
- Note: Versioned.
- Conversation messages
- Stored: Yes, in full
- Note: Both sides, including voice transcripts.
- Action attempts
- Stored: Yes
- Note: The action, its input, its risk tier and its outcome, refused or not.
- Action results
- Stored: For confirmed actions
- Note: Low-risk actions that ran without asking record the attempt, not the result.
- Voice audio
- Stored: No
- Note: Relayed and never written to disk. The transcript is kept.
- Uploaded documents
- Stored: Yes
- Note: The file, plus the embeddings used to search it.
- Launcher icons
- Stored: Yes, publicly
- Note: Readable by anyone with the URL, because every visitor to your app loads it.
- Your end users' passwords
- Stored: Never
- Note: Mihe has no part in how your users sign in to your app.
- Your staff's dashboard passwords
- Stored: Yes, as argon2id hashes
- Note: See above.
- Two-factor secrets
- Stored: Yes, encrypted
- Note: With the same master key as app secrets.
Keeping conversations and action inputs is deliberate. It is the audit trail, and you need it to review what the assistant did. It is also a liability, and both halves should be said together.
Who else sees it
- OpenAI
- What for: Model inference, typed and spoken
- What it sees: The system prompt, the conversation, the current screen including any data your app publishes to it, action results, and in a voice session the audio.
- DigitalOcean
- What for: Hosting the runtime and database, and storing launcher icons
- What it sees: Everything we store. Icons are served straight to browsers, so it also sees the IP address of every visitor to an app with an icon.
- Vercel
- What for: Hosting the dashboard and this site
- What it sees: Your staff's dashboard traffic, which passes through on its way to the runtime.
- Resend
- What for: Sending account email
- What it sees: Recipient addresses and the messages: verification, password-reset and invitation links. Never your end users or their conversations.
- SeerBit, or Paystack
- What for: Taking payment for credit
- What it sees: Your billing contact, country and card. Never conversations or app data.
Each of these sits behind an interface in our code, so this list describes today’s deployment rather than a fixed architecture. This page changes when it does.
Prompt injection
The threat is text inside your own data, a product name, a customer note, an uploaded document, trying to instruct the assistant. Our answer is the structure above rather than a filter:
- Injected text can only ask for actions the signed-in user is already allowed to run. Permissions are checked outside the model, against claims it never sees and cannot change.
- Anything consequential stops for a person, and the card they read is worded by your registry. Text smuggled into your data cannot change what they are shown they are approving.
- High-risk actions need a physical tap, so no amount of persuasion substitutes for a person.
- Every attempt, including the refused ones, lands in the audit log.
The honest limit: injection is contained to what the user may already do and what runs without asking. That is a real reduction, not elimination. A low-risk action with no confirmation can be triggered by injected content, which is the reason to set risk tiers deliberately rather than treating them as labels.
What your integration must do
These are not suggestions. An integration that skips them is insecure whatever the runtime does.
- Keep the secret key on your server. Anyone holding it can mint any user with any permissions. Never in a browser bundle, never in a
NEXT_PUBLIC_-style variable. - Read usage with the server key, not the secret key. They are separate on purpose.
- Mint short-lived user tokens from your own session, with the user’s real permissions.
- Verify the signature on every action call against the raw request body. Re-serialised JSON passes by luck, not by construction.
- Re-check permissions on your own endpoint, from your own database. The envelope’s permissions are empty by design, and your endpoint is the last place that can still say no.
- Take tenancy from your user record, never from the request. Mihe knows nothing of your tenancy and must not be able to influence it.
- Set the allowed origins. An empty list allows every origin.
- Keep the publish key in your CI’s secrets, and allow only your own backend’s origins under Backend endpoints. The key can rewrite your actions; the origins are what stop it sending calls anywhere else.
- Set risk tiers deliberately. Low risk means “runs without asking”, and that is the surface injection can reach.
- If you upload an icon and set a Content-Security-Policy, allow the storage domain in
img-src, or the widget falls back to the Mihe mark.
Reporting a problem
If you think you have found a vulnerability, email support@mihe.dev with what you found and how to reproduce it. Please give us a reasonable time to fix it before telling anyone else. If you have pasted a key anywhere it should not be, tell us and replace it: mint a new key in the dashboard and revoke the old one. We treat a pasted key as compromised.