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

  1. 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.

  2. 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.

  3. Handle the callback

    The callback route compares state first, then exchanges the code at POST /oauth/token, then drops the verifier so a replayed callback has nothing to work with.

  4. Read the person and their CV

    /me reads GET /v1/me and, when cv:read was 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 durable pdf.url next to a proxied /cv.pdf.

  5. Refresh and disconnect

    Two buttons. Refresh rotates the pair at POST /oauth/token. Disconnect revokes the refresh token at POST /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.

FieldTypeDescription
TABBIO_CLIENT_IDrequiredstring

Your app's client id.

TABBIO_CLIENT_SECRETstring

Confidential clients only. Omit it for a public client and PKCE carries the flow on its own.

TABBIO_ISSUERstring

The API origin. Defaults to https://server.tabbio.com.

TABBIO_AUTHORIZE_URLstring

The consent screen. Defaults to https://app.tabbio.com/oauth/authorize.

TABBIO_REDIRECT_URIstring

Must match a registered URI exactly. Each sample's default is on its card below.

TABBIO_SCOPEstring

Space separated. Defaults to profile email cv:read.

SESSION_SECRETstring

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

Download zip9.8 kB

The whole flow in one file, with Node's built-in fetch and crypto doing the work.

Run Express
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
  • .gitignore
  • README.md
  • package.json
  • server.js

Next.js

Next.js 16 App Router

Download zip15.2 kB

Route handlers for the flow, server components for the pages, and the session sealed into one encrypted cookie.

Run Next.js
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
  • .gitignore
  • README.md
  • app/api/auth/callback/route.ts
  • app/api/auth/disconnect/route.ts
  • app/api/auth/login/route.ts
  • app/api/auth/refresh/route.ts
  • app/api/cv.pdf/route.ts
  • app/globals.css
  • app/layout.tsx
  • app/me/page.tsx
  • app/page.tsx
  • app/tabbio-mark.tsx
  • lib/session.ts
  • lib/tabbio.ts
  • next.config.mjs
  • package.json
  • tsconfig.json

Flask

Python 3.11 and Flask 3

Download zip10.8 kB

One Flask app, Jinja templates, and PKCE from the standard library's secrets and hashlib.

Run Flask
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
  • .gitignore
  • README.md
  • app.py
  • requirements.txt
  • templates/_mark.html
  • templates/base.html
  • templates/expired.html
  • templates/index.html
  • templates/me.html

PHP

PHP 8.2, no framework

Download zip11.3 kB

A router, a client class and curl. No Composer, no dependencies, nothing to install.

Run PHP
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
  • .gitignore
  • README.md
  • public/index.php
  • src/Tabbio.php
  • src/views.php

Laravel

Laravel 11 or 12

Download zip12.4 kB

A drop-in for an app you already have: copy four directories in, require one route file, done.

Run Laravel
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.example
  • README.md
  • app/Http/Controllers/TabbioAuthController.php
  • app/Services/TabbioClient.php
  • config/tabbio.php
  • resources/views/tabbio/expired.blade.php
  • resources/views/tabbio/index.blade.php
  • resources/views/tabbio/layout.blade.php
  • resources/views/tabbio/mark.blade.php
  • resources/views/tabbio/me.blade.php
  • routes/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.

lib/tabbio.js
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.

lib/tabbio.js
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_URI to 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:

Terminal
TABBIO_ISSUER=http://localhost:3001
TABBIO_AUTHORIZE_URL=http://localhost:8081/oauth/authorize

Each sample's home page prints the four values it is using, so a typo shows up before you click anything.

What next