Korat
Written for a careful reader

Accounts, access, and the parts we cannot do yet

This page is written the way it would be explained to another engineer: what protects your account, what protects your rows in the database, and — at the bottom, in the same size type — the list of things Korat does not do and should not be credited with.

Two ideas carry most of the weight. Your account is a real auth session, not a row we compare a password against. And the database, not the app, decides what you are allowed to read — so a modified client, or someone talking to the API directly with the public key, gets exactly the same answers as the app.

Accounts: bcrypt, a real session, and a bridge to your profile

Identity lives in Supabase Auth. Passwords are hashed with bcrypt on the server; the app never sees a password hash and there is no home-grown comparison anywhere in the login path. Signing in produces a JWT scoped to you, and a column on your profile row (auth_uid) is the only link between the auth user and the profile — which is what makes every policy below expressible.

You can sign in with an email and password, or with Google through Android's Credential Manager, which hands a real Google ID token to Supabase for exchange. Signing out everywhere kills the session globally.

There is a legacy PIN column left over from before Supabase Auth. Nothing reads it, new accounts are created with it empty, and it is queued for deletion. It is mentioned here only because it exists and you would find it.

Passkeys: verified on the server, with a replay guard

Korat supports passkeys — a key pair created inside your phone's secure hardware, unlocked with your fingerprint or face, that never leaves the device. The relying party is koratland.com, tied to the Android app through assetlinks.json, and the verification runs in a Cloudflare Worker rather than a library:

  • the challenge is single-use with a five-minute lifetime and the row is deleted when it is consumed
  • the rpIdHash in the authenticator data must equal SHA-256 of koratland.com
  • the user-present flag must be set
  • the signature is verified with WebCrypto — ECDSA P-256, or RS256
  • the signature counter must increase; a counter that repeats or goes backwards is rejected as a replay

Registration requires an existing session and takes the caller's identity from their bearer token, never from a user id in the request body — otherwise anyone could register a passkey onto anyone's account. The stored credential row has no update policy at all, so even its owner cannot rewrite their own public key or signature counter; renaming a passkey goes through a function that can only touch the label.

One deliberate omission: attestation is not verified. Korat does not check which manufacturer made the authenticator, so nothing here should be read as a hardware-attested claim.

Honest status. Passkey *registration* is verified working. Passkey *login* has not been run end to end yet, and the web app has no passkey support at all — it is an Android feature in rollout. Email and password, and Google sign-in, are the paths in daily use.

Row-level security: the database answers, not the app

Row-level security is enabled across the database. Policies are written in terms of four functions, and almost every rule in Korat is one of them:

app_uid()
The caller's profile id, or null when nobody is signed in. The basis of every own-row-only policy.
is_member_of(business_id)
True for the owner, or anyone with an active membership row for that business.
has_capability(business_id, capability)
Expands the role presets — owner, manager, waiter, kitchen — server-side, so the database never trusts the client's idea of what a role means.
can_review(business_id)
Reads the eligibility view, which makes “only real customers can review” a property of the database rather than a promise the app makes.

Three rules were learned expensively enough to be worth stating. A policy written only from the shop's point of view excludes the customer — a dining session, a staff call and a transaction all have two legitimate readers, so they read user_id = app_uid() or is_member_of(business_id). A child table must be readable exactly as far as its parent, so comments and likes delegate to the post rather than carrying their own idea of who may read them. And a definer function runs as the owner, so it has to check permission itself.

The thing RLS cannot do, and what replaces it

Row-level security can answer *who is calling*. It can never answer *did this request carry a valid token* — a table QR, a share link, an invitation. So every token-gated path in Korat is a SECURITY DEFINER function that checks the token itself, and the table stays closed.

Joining a table. Someone about to scan a QR is neither the host of the session nor a member of staff, so they cannot read the dining-session table at all — not even to look up the token. join_session_by_token matches the token server-side, makes the first scanner the host, adds the participant, and returns nothing at all when the token is wrong rather than a message you could use to narrow down a guess.

Share links. resolve_share returns the kind and target of a link and nothing else — never who shared it, never the click count. Counting a click goes through record_share_click, so anyone can be counted without any insert permission on the analytics table; there is deliberately no insert policy for anyone.

Money. Placing an order runs entirely on the server. It prices every line itself and reads the delivery fee out of the business's own row, ignoring whatever the client sent — because a fee the client can set is a discount the client can grant themselves.

Contacts, devices and the sign-in you did not make

A verification code for a backup email is generated by a function only the service role can call. Only the SHA-256 of the code is stored, it expires in ten minutes, it allows five attempts, and it is rate-limited to one request a minute. The Worker that sends the mail never echoes the code back to the app.

You cannot mark your own contact verified. A database trigger forces the verified flag to false on any client write, and only a definer function that has actually checked a code can set it. That looks paranoid until you follow it through: without it, adding your address to someone else's account and pressing “forgot password” is a complete takeover. For the same reason a contact must be verified before it can be made primary — the primary address is the one the system mails.

Every signed-in device is listed in Settings and can be revoked individually. The device table has no insert, update or delete policy at all, because a device that has just been revoked still holds a valid token and would otherwise be able to clear its own revocation. Revoking a device deletes the underlying session, so the refresh token dies with it.

A sign-in from a new device writes the in-app alert in the same transaction that creates the device row, so it cannot be lost, and mails you as well — to verified contacts only, because an unverified address may belong to somebody else. The mail fires at most once per device and only for a device created in the last hour, so the endpoint cannot be turned into a way to flood the account owner.

The limit of revocation, stated plainly. An access token that has already been issued lives about an hour and cannot be recalled mid-flight. A revoked device that is offline keeps working until its token expires. That is the nature of JWTs, not a bug, and it is why the device list exists rather than a claim that revocation is instant.

What you can do about your own account

Account deletion is self-service, from Settings — no email to anyone. It is a *request* that is recorded and can be cancelled, not a button you hit at two in the morning and cannot undo. Its status is readable inside the app. privacy@koratland.com is still there for the other PDPA rights: access, correction, portability and withdrawal of consent.

Reporting covers content as well as people — profiles, posts, comments, stories, messages and business pages. What happens to a report involving a minor is set out in the child safety standards. The function that takes a report is SECURITY INVOKER, the only one in the whole back office that is. As a definer it would *see* hidden and private posts and answer differently for them, which turns a report button into an oracle for content you are not allowed to see. Run as the caller, "no such post" and "not yours to see" collapse into one answer — which is honest, because from where the reporter stands those are the same thing. The rate limit lives in a trigger, not in the app.

What Korat does not do

This list is the reason the rest of the page is worth reading. Everything here is true today.

  • Nothing is end-to-end encrypted. Messages, files and calls are protected in transit by TLS and at rest by row-level security. The server can read them. Korat has no device keys and no key management, and should not be chosen for anything that needs them.
  • There is no application-level encryption at rest beyond what the managed database provides.
  • No identity document has ever been checked. There is no eKYC. Nothing in Korat says “verified identity”, and the trust badges say what evidence exists instead.
  • Phone numbers cannot be verified — there is no SMS provider connected, and the endpoint says so rather than pretending.
  • Uploaded media is publicly readable by URL. Avatars, post images and chat images live in public buckets; writing requires a session, but a URL that leaks is a file that can be fetched. There are no signed URLs yet.
  • Permission checks in the business console currently run on the device, with the same matrix available server-side as has_capability(). Treat console permissions as an organisational control, not a security boundary, until that rollout is finished.
  • Calls have no TURN server and are not end-to-end encrypted; some cross-network calls will fail to connect.
  • There are no database backups on the current plan, and no automated test suite.
  • The web app has no push notifications. Push is an Android-app feature; on the web you see a notification when you open the tab, not when it arrives.

If any of that changes, this page changes with it. It would be easier to write a paragraph about bank-grade encryption and move on; this is more useful.

Frequently asked questions

Are Korat messages end-to-end encrypted?

No. Messages travel over TLS and are protected in the database by row-level security policies that only let the two participants in a conversation read them — but the server can read them. If you need end-to-end encryption, Korat is not the right tool for that conversation.

What happens if someone steals my password?

They would trigger a new-device alert, in the app and by email to your verified contacts, the first time they signed in from a device you have not used. You can revoke that device from Settings, which deletes its session, and change your password. Adding a passkey removes the password from the attack entirely on that device.

Can a shop see my profile because I ordered from them?

A shop sees what it needs to serve the order — your name on the table, the items, and, for delivery, the address you entered. Your profile fields each carry their own public / friends / private setting, enforced when anyone else views your profile. Loyalty and the customer list are built from the sales ledger, not from your profile.

Is Korat PDPA compliant?

Korat asks for PDPA consent before an account is created, behind a screen you must scroll to the bottom of, with a real decline path. It states what is collected and why, does not sell personal data, and supports access, correction, deletion, objection, portability and withdrawal of consent — account deletion is self-service from Settings in the app and the request can be cancelled; the rest go through privacy@koratland.com. Compliance is a continuing obligation rather than a badge, and the gaps listed above are part of the honest answer.

How do I report a security problem?

Email hello@koratland.com with the details. There is no bounty programme, but a real report gets a real answer.

Questions this page did not answer?

Write to hello@koratland.com. Security questions get a direct answer, including “we have not done that yet”.