Sign in with Tabbio

Tokens

Lifetimes, refresh rotation and its reuse detection, revocation, and how to tell an expired token from a revoked one.

Lifetimes

FieldTypeDescription
Authorization code10 minutes

Single use. A second exchange revokes every token minted from it.

Access token1 hour

Sent as Authorization: Bearer on every /v1 call.

Refresh token90 days

Rotated on every use. The 90 days runs from the most recent rotation.

Durable CV linkNo expiry

A signed URL rather than a token you hold. Lives as long as the connection.

An app that is used at least once every 90 days keeps working indefinitely without the person seeing anything.

Refreshing

POSThttps://server.tabbio.com/oauth/token
Terminal
curl -X POST https://server.tabbio.com/oauth/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=refresh_token" \
  -d "refresh_token=$TABBIO_REFRESH_TOKEN" \
  -d "client_id=$TABBIO_CLIENT_ID" \
  -d "client_secret=$TABBIO_CLIENT_SECRET"

Rotation, and why a lost response logs somebody out

Every refresh returns a new refresh token and revokes the one you sent. That is rotation, and it is what makes a stolen refresh token detectable.

If a rotated token is presented again, Tabbio assumes one of two copies is an attacker and revokes the entire family: every access and refresh token descended from that original grant. The person has to connect again.

This has a real failure mode. If your refresh request succeeds but you lose the response, you are holding a revoked token and the next refresh kills the connection. Guard against it:

  • Persist before you use. Write the new pair to storage inside the same transaction that clears the old one, then return.
  • Refresh in one place. Two workers refreshing the same connection at the same moment is the reuse case, self-inflicted. Take a per-connection lock.
  • Do not retry a failed refresh with the same token. If a refresh returns invalid_grant, the token is gone. Retrying cannot help and may be what trips the family revocation.
refresh-once.js
// One in-flight refresh per connection. Everyone else awaits the same promise
// instead of sending a second request with the same token.
const inflight = new Map();

export function refreshOnce(connectionId, run) {
  const existing = inflight.get(connectionId);
  if (existing) return existing;

  const promise = run().finally(() => inflight.delete(connectionId));
  inflight.set(connectionId, promise);
  return promise;
}

Narrowing scope on refresh

Pass scope with a subset of what you hold and the new token carries only that. Passing a scope you were not granted returns invalid_scope. You cannot widen a grant this way; that needs a new trip through the authorization flow.

Revoking

POSThttps://server.tabbio.com/oauth/revoke

RFC 7009. This is what a "Disconnect Tabbio" button in your product should call.

Terminal
curl -X POST https://server.tabbio.com/oauth/revoke \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "token=$TABBIO_REFRESH_TOKEN" \
  -d "token_type_hint=refresh_token" \
  -d "client_id=$TABBIO_CLIENT_ID" \
  -d "client_secret=$TABBIO_CLIENT_SECRET"
  • Revoking a refresh token ends the whole family, the connection's live access tokens and its durable CV links, so a partner ending the session keeps no bearer PDF URL. Use this to disconnect.
  • Revoking an access token affects only that token. The refresh token still works, so this is a session logout rather than a disconnect.

It always answers { "data": { "revoked": true } } with a 200, including for a token that was already gone. That is deliberate: a different answer would let somebody probe which tokens exist.

Revoke keeps the envelope, unlike its neighbours

RFC 7009 specifies no success body and tells clients to ignore whatever arrives with the 200, so this endpoint keeps the standard { data, error, meta } envelope. The token and introspection endpoints answer the flat OAuth bodies their own RFCs define.

Telling expired apart from revoked

Both look identical at the call site:

JSON
{
  "data": null,
  "error": { "code": "OAUTH_INVALID_TOKEN", "message": "Token is not active." },
  "meta": null
}

The difference is what happens next.

FieldTypeDescription
ExpiredRecoverable

The refresh succeeds and the new access token works. Normal, hourly, invisible to the person.

RevokedNot recoverable

The refresh returns invalid_grant. The person disconnected you, or reuse detection fired. Ask them to connect again.

If you need to know before making a call, introspect.

POSThttps://server.tabbio.com/oauth/introspect
Terminal
curl -X POST https://server.tabbio.com/oauth/introspect \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -u "$TABBIO_CLIENT_ID:$TABBIO_CLIENT_SECRET" \
  -d "token=$TABBIO_ACCESS_TOKEN"

This is one of the two endpoints that answer the flat RFC body rather than the envelope: active is at the top level, and the response carries Cache-Control: no-store.

A live token this app owns
{
  "active": true,
  "scope": "profile email cv:read cv:contact",
  "client_id": "tbo_ci_2Kd81aQ7vXpL9mNr3TzW",
  "token_type": "Bearer",
  "sub": "usr_2f8a91",
  "exp": 1788000000,
  "connection_id": "con_9d21f4"
}

exp is a Unix timestamp, and null for a token that does not expire on a clock.

Anything else
{ "active": false }

Unknown, expired, revoked, and a live token that belongs to another app all answer the same two-character body, so there is no oracle for guessing token values.

introspect.js
const response = await fetch("https://server.tabbio.com/oauth/introspect", {
  method: "POST",
  headers: {
    "Content-Type": "application/x-www-form-urlencoded",
    // HTTP Basic, or client_id and client_secret in the body.
    Authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString("base64")}`,
  },
  body: new URLSearchParams({ token: accessToken }),
});

// Flat body: no envelope to unwrap here.
const { active, scope, exp } = await response.json();

Errors here are flat too, in the RFC 6749 { error, error_description } shape, not the envelope. Client authentication is required, which is why introspection is a server-side tool and not something a mobile app should call.

Storing tokens

  • Encrypt refresh tokens at rest. They are long-lived credentials for somebody's CV.
  • Never log a token, not even truncated. The prefixes exist so a scanner can catch one that escapes; do not rely on that as a control.
  • Keep them out of URLs. A query string ends up in access logs, referrer headers and analytics.
  • Store scope alongside the tokens, so a feature check is a local lookup rather than a 403 the person has to see.