Login with Rabotka: OIDC Sign-In with Better-Auth

September 2, 2026

Login with Rabotka: OIDC Sign-In with Better-Auth

Rabotka is an OpenID Connect provider. Your users sign in with their Rabotka account, and you receive their identity plus Rabotka's KYC verdict on them — a human administrator has read that person's government ID, compared it against a selfie, and made a decision.

That verdict is the whole reason to integrate. Any identity provider can tell you a phone number is reachable.

Better-Auth, OpenID Connect, and Rabotka

Rabotka's own developer documentation shows the flow written out longhand — Express, PHP, curl. That is the right way to document a protocol, but it is not how most of us ship. If you are already on Better-Auth, this is roughly three lines of config.

Roughly. There are four things that will stop you, and none of them are in either project's docs:

  1. Rabotka doesn't send an email claim. Better-Auth's user.email is required and unique. Sign-in dies with email_is_missing.
  2. Better-Auth changed its generic-OAuth callback path in 1.7. Rabotka matches redirect URIs byte-for-byte. Get this wrong and the consent screen never renders.
  3. rabotka_verified isn't a standard OIDC claim, so it doesn't reach your database on its own.
  4. There are no refresh tokens. On purpose.

This post walks the whole thing, traps included. I'm writing against Better-Auth 1.7.x; there's a note further down for 1.6.

Before any code: get credentials

There is no self-service registration and no dynamic client registration. Rabotka vouches for its users' identities to whoever holds a client_id, so it's a conversation, not a form. You send them:

NameShown on the consent screen. This is the name your users decide whether to trust.
DescriptionA sentence or two, also on the consent screen.
Logo URLOptional, HTTPS.
Redirect URIsEvery callback URL you will use, written out in full.
Contact emailWhere they reach you.

You get back a client_id and a client_secret. The secret is shown once — Rabotka stores only a hash and cannot show it to you again. If it's lost, an administrator rotates it.

Ask for the sandbox in the same message

Building an integration shouldn't require a real Rabotka account and a real phone. Ask for a sandbox application at the same time — it gets its own client_id and client_secret and authenticates exactly two fixed users, with a fixed code and zero WhatsApp messages:

+242 06 111 1111 → rabotka_verified: true +242 06 111 1112 → rabotka_verified: false Verification code : 111111

There are two of them because there are two answers to handle, and an integration that has only ever seen true ships without the branch that matters.

There is no separate sandbox URL. Same issuer, same discovery document, same endpoints. The client_id you present is the only thing that decides. PKCE is real, the RS256 signature is real and verifies against the same JWKS — code that works against the sandbox works in production without a line changed.

Two consequences worth knowing up front: a sandbox app authenticates only those two numbers (yours included will get "no account"), and the sub you see there is different from the one the same person would present to your production app. Don't carry sandbox rows into a real database.

Register the exact redirect URI

This is the one to get right before you write anything, because Better-Auth derives the URI and Rabotka compares it literally.

On Better-Auth 1.7.x, the generic OAuth plugin uses the core callback route, so for a provider you'll call rabotka:

https://you.com/api/auth/callback/rabotka http://localhost:3000/api/auth/callback/rabotka

Register both. Rabotka accepts http:// on localhost only, which is exactly what you need for development.

https://you.com/api/auth/callback/rabotka and https://you.com/api/auth/callback/rabotka/ are different URIs. So are http:// and https://. There are no wildcards and no pattern matching — a loosely matched redirect URI is an open redirector, and an open redirector on an authorization endpoint hands your users' authorization codes to whoever asks.

If the URI doesn't match a registered one, Rabotka renders the error on its own page instead of redirecting, because redirecting in that case is the vulnerability. So the symptom is "I click the button and get a Rabotka error page," not a callback with an error code.

On Better-Auth 1.6 and earlier the path is /api/auth/oauth2/callback/rabotka — note the extra oauth2 segment. If you're on 1.6, register that instead. Upgrading to 1.7 means registering a new redirect URI, so do them together.

The configuration

Install it:

npm install better-auth

Then the provider. Everything below discoveryUrl is optional in the sense that discovery fills it in — Rabotka publishes a conforming document and Better-Auth reads the authorization, token, userinfo and JWKS endpoints straight out of it.

import { betterAuth } from "better-auth"; import { genericOAuth } from "better-auth/plugins"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { db } from "@/db"; export const auth = betterAuth({ database: drizzleAdapter(db, { provider: "pg" }), plugins: [ genericOAuth({ config: [ { providerId: "rabotka", clientId: process.env.RABOTKA_CLIENT_ID!, clientSecret: process.env.RABOTKA_CLIENT_SECRET!, discoveryUrl: "https://api.rabotka.work/.well-known/openid-configuration", scopes: ["openid", "profile", "phone", "rabotka:verification"], pkce: true, // the default on 1.7 — Rabotka requires it either way }, ], }), ], });

PKCE is mandatory at Rabotka, including for confidential server-side clients. Your client secret does not protect the authorization code on its trip back through the user's browser; only the verifier does. Better-Auth defaults pkce to true on 1.7, but write it out — on 1.6 the default was the other way.

Scopes are granted, not requested

Your application is granted a set of scopes at registration. Asking for one you weren't granted is an error, not a silent omission: you get invalid_scope at /authorize rather than shipping against a claim that never arrives.

ScopeClaims you receive
openidsub — required
profilegiven_name, family_name, name, picture
phonephone_number (E.164), phone_number_verified
rabotka:verificationrabotka_verified, rabotka_verified_at, rabotka_account_status
emailemail, email_verified — on request
rabotka:locationcity, country_code — on request

Trap #1: there is no email

Run the config above and the flow works right up until Better-Auth tries to create the user, then fails with email_is_missing.

Better-Auth's core user model has a required, unique email column. Rabotka is a phone-first identity provider — it authenticates people over WhatsApp OTP, and email is one of the scopes granted on request rather than by default. So there is nothing to put in that column.

Two honest ways out.

Ask Rabotka for the email scope, if your product genuinely needs to email people. Then add "email" to scopes and you're done — nothing else in this section applies.

Or synthesize a placeholder from sub, if you don't. sub is stable and unique for your application forever, which is exactly what the column needs:

mapProfileToUser: (profile) => ({ email: `${profile.sub}@rabotka.invalid`, emailVerified: false, name: profile.name, image: profile.picture, }),

.invalid is reserved by RFC 2606 and can never resolve, which is the point: this address exists to satisfy a unique constraint and nothing else. Never send mail to it, never show it in the UI, and if you have a "change your email" flow, make sure it can tell a real address from this one.

If you skip mapProfileToUser entirely you'll also see name_is_missing on accounts where the profile scope wasn't granted — mapping name explicitly, as above, covers that too.

Trap #2: keeping the verdict

rabotka_verified is the claim you integrated for, and by default it evaporates. Better-Auth writes the columns it knows about; a non-standard claim needs a column and a mapping.

Declare the columns:

export const auth = betterAuth({ database: drizzleAdapter(db, { provider: "pg" }), user: { additionalFields: { phoneNumber: { type: "string", required: false, input: false }, rabotkaVerified: { type: "boolean", required: false, defaultValue: false, input: false, }, rabotkaVerifiedAt: { type: "string", required: false, input: false }, }, }, // ...plugins });

input: false matters. These fields are Rabotka's assertions about a person, not something a user may submit — without it, anyone who can hit your update-user endpoint can set rabotkaVerified: true on themselves. That would be a fairly complete defeat of the entire feature.

Then map them, and tell the plugin to refresh them:

{ providerId: "rabotka", clientId: process.env.RABOTKA_CLIENT_ID!, clientSecret: process.env.RABOTKA_CLIENT_SECRET!, discoveryUrl: "https://api.rabotka.work/.well-known/openid-configuration", scopes: ["openid", "profile", "phone", "rabotka:verification"], pkce: true, overrideUserInfo: true, mapProfileToUser: (profile) => ({ email: `${profile.sub}@rabotka.invalid`, emailVerified: false, name: profile.name, image: profile.picture, phoneNumber: profile.phone_number, rabotkaVerified: profile.rabotka_verified ?? false, rabotkaVerifiedAt: profile.rabotka_verified_at ?? null, }), }

overrideUserInfo: true is the important line and it defaults to false. Without it, Better-Auth populates these fields once at signup and never touches them again — so a user who was unverified when they first signed in stays rabotkaVerified: false in your database forever, no matter what Rabotka says today. Since verification is precisely the thing that changes after signup, you want the refresh.

Then generate the schema:

npx @better-auth/cli generate

The handler, the client, the button

Standard Better-Auth from here. The route handler:

import { auth } from "@/lib/auth"; import { toNextJsHandler } from "better-auth/next-js"; export const { POST, GET } = toNextJsHandler(auth);

The client — note there is no client plugin on 1.7; generic OAuth providers are registered as first-class social providers:

import { createAuthClient } from "better-auth/react"; export const authClient = createAuthClient({ baseURL: process.env.NEXT_PUBLIC_APP_URL, });

And the button:

"use client"; import { authClient } from "@/lib/auth-client"; export function SignInWithRabotka() { return ( <button onClick={() => authClient.signIn.social({ provider: "rabotka", callbackURL: "/dashboard", }) } > Se connecter avec Rabotka </button> ); }

On Better-Auth 1.6 and earlier, this part is different in two places. You need the client plugin — import { genericOAuthClient } from "better-auth/client/plugins", then plugins: [genericOAuthClient()] in createAuthClient — and the call is authClient.signIn.oauth2({ providerId: "rabotka", callbackURL: "/dashboard" }). Note providerId, not provider.

That's the integration. What follows is the part that decides whether it survives contact with real users.

Key your users on sub, never on the phone number

Rabotka's sub is pairwise: it's derived from your application and the person together. The same user signing in to a different partner presents a completely different sub, and the two cannot be linked — two partners comparing databases cannot discover they share a user.

For you, it never changes. Better-Auth already stores it as account.accountId with providerId: "rabotka", so the correct lookup is already happening; the thing to avoid is adding your own.

Do not key on phone_number. People change numbers, carriers recycle them, and a recycled number would silently merge two human beings into one account. It's a tempting index precisely because it's the thing the user typed, which is what makes it worth stating.

phone_number_verified is not rabotka_verified

This is the distinction to not get wrong, and the names actively invite getting it wrong.

  • phone_number_verified is always true. It means a one-time code was delivered over WhatsApp and typed back in during this login. It proves control of the number. That is all it proves.
  • rabotka_verified is a Rabotka administrator's decision after reviewing a government ID and a selfie against the account.

An account can have a verified phone and rabotka_verified: false — that's the normal state of a new signup, not an anomaly. If your gate is "did they verify their identity," phone_number_verified answers a different question and answers it true every single time.

There's also rabotka_account_status: ACTIVE, PENDING_ACTIVATION or SUSPENDED. Banned accounts can't authenticate at all, so you'll never see one.

No refresh tokens, and an opaque access token

Two deliberate absences that will surprise you if you're used to other providers.

The access token is not a JWT. It's an opaque string, valid one hour, and its only use is GET /userinfo. That's on purpose — a partner handed two JWTs eventually verifies the wrong one, or reads claims out of the access token without verifying anything at all. Opaqueness also makes revocation immediate: when a user withdraws consent, the token stops working on the next request rather than at expiry.

There are no refresh tokens. grant_types_supported lists authorization_code and nothing else. You're not meant to hold a long-lived credential against Rabotka — you create your own user record and your own session from the ID token, and you own the session lifetime from there.

So the reflex to suppress is the background refresh. There's no getAccessToken path that will keep rabotkaVerified current on a cron. When you need fresh data, send the user through /authorize again: for a signed-in user with standing consent that's a redirect out and back with no interaction at all, which combined with overrideUserInfo: true updates the row. A sign-in link on a page they'll visit anyway is usually enough.

For genuine step-up moments — confirming a payment, say — you can force re-authentication:

authorizationUrlParams: { prompt: "login" },

Use it for those moments, not for ordinary sign-in. It costs your users an OTP round-trip every time.

When it breaks

ErrorWhat it actually means
email_is_missingBetter-Auth's, not Rabotka's. Your mapProfileToUser isn't producing an email.
invalid_scopeYou asked for a scope your application wasn't granted.
unauthorized_clientUnknown client_id, or the application is suspended.
invalid_clientClient authentication failed at /token — check the secret.
invalid_grantThe code is expired, already used, or the PKCE verifier is wrong.
access_deniedThe user refused, or their account may not use partner login.
interaction_requiredThe login session expired mid-flow. Start again.
invalid_tokenThe access token is expired, revoked, or invalid.

invalid_grant on a code you know you haven't used is almost always one of three things: more than 60 seconds elapsed (the code is single-use and short-lived), the redirect_uri doesn't byte-for-byte match the one sent to /authorize, or the user revoked consent in between. On Better-Auth the second one is the 1.6-versus-1.7 callback path.

A Rabotka error page instead of a redirect means the request was too broken to trust — an unregistered redirect_uri or an unknown client_id. That's not a bug, it's the open-redirector defence doing its job.

Worth knowing before you load-test: rate limits are per IP, per minute — /authorize 30, /token 60, /userinfo 120. OTP send is 5 and verify is 10, and five wrong codes burn the login session, which the user restarts from your app.

Where the protocol ends and your app begins

Everything above is plumbing. The line that matters is the one where you decide what rabotkaVerified unlocks — who can post a job, who can accept one, who gets a badge, who has to wait. That's a product decision, and it's the only part of this that's actually yours to write.

Rabotka's discovery document is at https://api.rabotka.work/.well-known/openid-configuration. The full protocol reference, error codes and all, lives at rabotka.work/developers/api-documentation.

GitHub
LinkedIn