ID Checks Behind Duleko's Verified Badge
How Duleko made its verified badge mean an ID was checked: photos in a private bucket, 10-minute links, and a badge only one Postgres function can grant.
On Duleko, a stranger might come to your house to fix the wiring, or you might go to theirs to tutor their kids. The verified tick beside a name is one of the few things that helps you decide whether to say yes. Until October 2026 it meant less than it looked like. Staff gave it after looking at a profile, so it said someone had a clear photo, some skills and a bio, not who they were. Now the badge means Duleko has seen that person's ID. This post covers how the documents are kept private, why only one Postgres function can hand out the badge, and why we took back the badges that had already been given.
What the badge used to mean
The badge has lived in its own table, profile_verifications, since the first staff tools, so nobody could give themselves one by editing their own profile. Any staff member could add a row from a profile's menu. When verification launched, every member got an alert asking them to complete their profile - a clear photo, their skills and a short bio - so the team could review it for the badge.
That checked whether a profile was complete. It couldn't check whether the person was who the profile said they were, and that's the question people are really asking when they see a tick next to a name. So the badge had to come from an ID, and the database had to make sure nothing else could produce it.
Four kinds of ID, one required
From Get verified, a member sends a photo of their citizenship certificate, national ID card, driving licence or passport. One is enough. They can send more, but only one of each kind. Cards need both sides, and a passport only its photo page. Before uploading, the app shrinks each photo to 2,000 pixels on its longest side: big enough for staff to read the name and number, small enough to send on a slow connection.
The photos go up first and the request to check them second, through a function called submit_kyc(). It checks again, because anyone can skip the screen and call the API directly: you're signed in, not staff, not already verified, and not waiting on another check. A partial unique index allows only one waiting check per member, and each ID needs its front, plus its back unless it's a passport. If submit_kyc() refuses, the app deletes the photos it just uploaded, so a failed attempt doesn't leave an ID sitting in storage.
Photos with no public address
The ID photos live in a storage bucket called kyc, which is private: no file in it has a public URL. It takes JPEG, PNG and WebP up to 5 MB. Each member uploads into a folder named after their own user ID, and the storage policies let you add or delete files only in your own folder and read only your own, unless you're staff.
Staff can read every folder, which opened a quieter hole. A request to check an ID only names file paths, so a member could name a path in someone else's folder, and staff would see a stranger's ID under that member's name. submit_kyc() closes it by refusing any path that doesn't start with the caller's own folder.
Even staff never get a lasting link. The ID checks page asks for signed links when it shows a check, and each one expires after 10 minutes. A link copied out of that page, pasted into a chat or left in a browser history stops working after 10 minutes instead of opening someone's ID for good.
One function hands out the badge
The migration removed the policy that let staff add rows to profile_verifications from the app. Staff can still remove a badge, but nobody can add one through the app's API, not even an admin. The only code that adds one is review_kyc(), a security definer function that first checks the caller is staff.
review_kyc() locks the check's row before reading it. If two staff members open the same ID at once and both press a button, the second one waits for the first, then finds the check already decided and gets an error instead of a second decision. Approving marks the check approved and adds the badge, and the trigger that already sent the "Your profile is verified" alert does the rest, now saying Duleko checked their ID.
Turning an ID down needs a reason, picked from a fixed list: the photo is blurry or hard to read, the name doesn't match the profile, part of the document is missing or cut off, or it isn't an accepted ID or has expired. The reason is stored as a code, not a sentence, so the member reads it in their own language, English or Nepali. Staff can add a note of up to 300 characters, and must when the reason is "other". The alert ends by pointing back to Get verified.
On the staff side, the queue shows the oldest check first, with the member's profile photo beside their documents, so the face on the ID can be compared with the face on the profile.
A new name means a new check
The badge vouches for the name on the ID, not whatever the profile says later. So a trigger on the profiles table removes the badge when someone changes their name. It compares names in lower case with spaces tidied up first, so fixing "ram bahadur" to "Ram Bahadur" keeps the badge, while a different name loses it until a new ID is checked.
Staff don't go through ID checks. Their staff badge comes from the staff_roles table and already says they speak for Duleko, so submit_kyc() turns them away instead of putting them in their own queue.
Taking the old badges back
A dozen members had a badge from the old profile review. The first migration left them alone. The next one deleted them, with a guard that it would never touch anyone on staff.
That's a real cost: a dozen people lost a tick they had done nothing wrong to lose. But a badge that means two different things means neither, because nobody looking at a profile can tell which kind they're looking at. Every badge on Duleko now stands for an ID that staff have seen. The welcome alert every new member gets now says the same thing: complete your profile, then get verified.
What it doesn't do
ID checks are deliberately simple for now, and a few things are left out on purpose or not built yet:
- No automatic reading of the ID and no face matching. A person on the team looks at every document, with the profile photo beside it.
- The photos stay in the private bucket after a check is decided, until the member deletes their account, which clears their folder along with everything else. A time limit on keeping them after review is the obvious thing to tighten next.
- The badge says who someone is, not whether they're good at the job. That is still what ratings and reviews are for.
This followed the same rule as the rest of Duleko: if the app shouldn't do something, the database shouldn't let it. The screens for ID checks are just a nicer way to call two functions. What makes the badge trustworthy is that there's no other way to get one, no lasting link to anyone's ID, and no way to change your name and keep a tick that vouched for the old one.
