Resources
Sample apps
Complete Sign in with Tabbio integrations for Express, Next.js, Flask, PHP and Laravel. Download one, set two environment variables, run it.
Five working integrations: Express, Next.js, Flask, plain PHP, and a Laravel drop-in for an app you already have. Each is a self-contained download: unzip it, set two environment variables, run it, and click Continue with Tabbio. They all point at production out of the box, so the only thing you supply is your own client id.
They do the same thing in the same order, so the one for your stack is the one to read, and the differences between them are the differences between the frameworks rather than between five ideas of how this works.
What every sample does
Show a button
GET /renders Continue with Tabbio, and prints the four values it is configured with so you can check them before clicking anything.Start the flow
The login route creates a PKCE verifier and a random
state, stores both against the session, and redirects to the consent screen with only the SHA-256 of the verifier.Handle the callback
The callback route compares
statefirst, then exchanges the code atPOST /oauth/token, then drops the verifier so a replayed callback has nothing to work with.Read the person and their CV
/mereadsGET /v1/meand, whencv:readwas granted,GET /v1/me/cv. It prints the name, email, headline, granted scopes, the shared CV's title and the CV document as JSON, and links the durablepdf.urlnext to a proxied/cv.pdf.Refresh and disconnect
Two buttons. Refresh rotates the pair at
POST /oauth/token. Disconnect revokes the refresh token atPOST /oauth/revoke, which ends the live access tokens and the durable CV links with it.
An expired access token answers 401, and every sample renders that as a plain "connect again" page with a Refresh button on it rather than as an error.
Configure
Create the app first on the developer platform, under
Applications. You get a client id straight away; a confidential app also shows a
client secret once, so copy it then.
Register the sample's redirect URI on the app before the first run, and tick the
profile, email and cv:read scopes.
Every sample reads the same variables and ships the same .env.example.
| Field | Type | Description |
|---|---|---|
| TABBIO_CLIENT_IDrequired | string | Your app's client id. |
| TABBIO_CLIENT_SECRET | string | Confidential clients only. Omit it for a public client and PKCE carries the flow on its own. |
| TABBIO_ISSUER | string | The API origin. Defaults to https://server.tabbio.com. |
| TABBIO_AUTHORIZE_URL | string | The consent screen. Defaults to https://app.tabbio.com/oauth/authorize. |
| TABBIO_REDIRECT_URI | string | Must match a registered URI exactly. Each sample's default is on its card below. |
| TABBIO_SCOPE | string | Space separated. Defaults to profile email cv:read. |
| SESSION_SECRET | string | Signs or encrypts the session where the sample keeps one of its own. Random per run when it is empty, which signs everybody out on a restart. |
Redirect URI matching is exact
A trailing slash is a different URI, and so is a different scheme. Loopback URIs match on any port, so running a sample on a port other than its default needs no second registration. Every other host does.
Download one
Express
Node 22 and Express 5
The whole flow in one file, with Node's built-in fetch and crypto doing the work.
npm install
TABBIO_CLIENT_ID=tbo_ci_... \
TABBIO_CLIENT_SECRET=tbo_cs_... \
npm start- Redirect URI
http://localhost:4321/callback- Start here
server.js
5 files
.env.example.gitignoreREADME.mdpackage.jsonserver.js
Next.js
Next.js 16 App Router
Route handlers for the flow, server components for the pages, and the session sealed into one encrypted cookie.
npm install
cp .env.example .env.local # fill in the first two values
npm run dev- Redirect URI
http://localhost:4321/api/auth/callback- Start here
lib/tabbio.ts
18 files
.env.example.gitignoreREADME.mdapp/api/auth/callback/route.tsapp/api/auth/disconnect/route.tsapp/api/auth/login/route.tsapp/api/auth/refresh/route.tsapp/api/cv.pdf/route.tsapp/globals.cssapp/layout.tsxapp/me/page.tsxapp/page.tsxapp/tabbio-mark.tsxlib/session.tslib/tabbio.tsnext.config.mjspackage.jsontsconfig.json
Flask
Python 3.11 and Flask 3
One Flask app, Jinja templates, and PKCE from the standard library's secrets and hashlib.
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
TABBIO_CLIENT_ID=tbo_ci_... \
TABBIO_CLIENT_SECRET=tbo_cs_... \
.venv/bin/python app.py- Redirect URI
http://localhost:4321/callback- Start here
app.py
10 files
.env.example.gitignoreREADME.mdapp.pyrequirements.txttemplates/_mark.htmltemplates/base.htmltemplates/expired.htmltemplates/index.htmltemplates/me.html
PHP
PHP 8.2, no framework
A router, a client class and curl. No Composer, no dependencies, nothing to install.
TABBIO_CLIENT_ID=tbo_ci_... \
TABBIO_CLIENT_SECRET=tbo_cs_... \
php -S localhost:4321 -t public- Redirect URI
http://localhost:4321/callback- Start here
public/index.php
6 files
.env.example.gitignoreREADME.mdpublic/index.phpsrc/Tabbio.phpsrc/views.php
Laravel
Laravel 11 or 12
A drop-in for an app you already have: copy four directories in, require one route file, done.
cp -R app config resources routes /path/to/your-laravel-app/
echo "require __DIR__.'/tabbio.php';" >> routes/web.php
php artisan serve- Redirect URI
http://localhost:8000/tabbio/callback- Start here
app/Http/Controllers/TabbioAuthController.php
11 files
.env.exampleREADME.mdapp/Http/Controllers/TabbioAuthController.phpapp/Services/TabbioClient.phpconfig/tabbio.phpresources/views/tabbio/expired.blade.phpresources/views/tabbio/index.blade.phpresources/views/tabbio/layout.blade.phpresources/views/tabbio/mark.blade.phpresources/views/tabbio/me.blade.phproutes/tabbio.php
Pick the parts worth copying
The five samples differ in syntax and agree on everything else. These are the two pieces worth lifting, in Node; the same shape is in the Python, PHP and Laravel files under the same names.
The PKCE pair
The verifier never leaves your server. Only its SHA-256 travels with the redirect, which is what stops a stolen authorization code from being spent by anyone else.
import crypto from "node:crypto";
export function createPkce() {
const verifier = crypto.randomBytes(32).toString("base64url");
const challenge = crypto
.createHash("sha256")
.update(verifier)
.digest()
.toString("base64url");
return { verifier, challenge };
}
export function authorizeUrl({ clientId, redirectUri, scope, state, challenge }) {
const url = new URL("https://app.tabbio.com/oauth/authorize");
url.searchParams.set("response_type", "code");
url.searchParams.set("client_id", clientId);
url.searchParams.set("redirect_uri", redirectUri);
url.searchParams.set("scope", scope);
url.searchParams.set("state", state);
url.searchParams.set("code_challenge", challenge);
url.searchParams.set("code_challenge_method", "S256");
return url.toString();
}Store verifier and state against the visitor's session before you redirect. Nothing
sensitive travels in the URL.
The token exchange
State first, exchange second. The token endpoint takes a form-encoded body and answers a
flat OAuth error rather than the { data, error, meta } envelope the rest of the API
uses, so this is the one place you read payload.error instead of payload.error.code.
export async function exchangeCode({ code, verifier, clientId, clientSecret, redirectUri }) {
const response = await fetch("https://server.tabbio.com/oauth/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: redirectUri,
code_verifier: verifier,
client_id: clientId,
// Public clients omit this and rely on PKCE alone.
...(clientSecret ? { client_secret: clientSecret } : {}),
}),
});
const payload = await response.json();
if (!response.ok) {
throw new Error(`${payload.error}: ${payload.error_description ?? ""}`);
}
return payload;
}The response carries scope, and that is the set you actually hold. A person can grant
fewer scopes than you asked for, so check it before calling an endpoint that needs
cv:read rather than meeting the gap as a 403 later. Every sample keeps the granted
list beside the tokens for exactly that reason.
refresh_token and revoke are the same call with a different grant_type and a
different path. Tokens has the rotation and reuse rules.
Before you copy one into production
Every sample makes the same four simplifications
They keep the code short. All four are wrong in a real deployment.
- Sessions are in-process or in a cookie. A restart or a second instance signs everybody out. Use a session store.
- Refresh tokens sit in plain text. Keep them in your database with encryption at rest, and hold nothing but a session id in the cookie.
- The cookie is not
secure. Turn it on the moment you are behind https. - There is no refresh lock. Two concurrent refreshes of one connection present the same rotated token, and reuse detection revokes the family. Take a per-connection lock, as in Tokens.
Errors also render straight to the page, which is fine for a sample and no way to run a service. Log them, and never log a token.
Going to production
- Register an https redirect URI on the app and set
TABBIO_REDIRECT_URIto it. Loopback URIs are for development. - Keep the client secret on the server. It never belongs in a browser bundle, a mobile binary, or a repository. A public client omits it entirely, and PKCE carries the flow.
- Rotate the secret on the developer platform, under the app's Credentials tab, if it leaks. The old one stops working the moment you rotate, so deploy the new one first.
Running against a local Tabbio
Point the two host variables at your own stack and leave the rest alone:
TABBIO_ISSUER=http://localhost:3001
TABBIO_AUTHORIZE_URL=http://localhost:8081/oauth/authorizeEach sample's home page prints the four values it is using, so a typo shows up before you click anything.
What next
Every parameter, every error, and what happens when a person declines.
Scopes and consentWhat the person sees, and how to handle a partial grant.
Reading the live CVThe CV document, the PDF, and polling that costs almost nothing.
TokensRefresh, rotation, revocation, and telling expired apart from revoked.