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
| Field | Type | Description |
|---|---|---|
| Authorization code | 10 minutes | Single use. A second exchange revokes every token minted from it. |
| Access token | 1 hour | Sent as Authorization: Bearer on every /v1 call. |
| Refresh token | 90 days | Rotated on every use. The 90 days runs from the most recent rotation. |
| Durable CV link | No 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
https://server.tabbio.com/oauth/tokencurl -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"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: "refresh_token",
refresh_token: stored.refreshToken,
client_id: process.env.TABBIO_CLIENT_ID,
client_secret: process.env.TABBIO_CLIENT_SECRET,
}),
});
const tokens = await response.json();
// Both values are new. Persist them before you use them.
await save({ accessToken: tokens.access_token, refreshToken: tokens.refresh_token });import os
import requests
response = requests.post(
"https://server.tabbio.com/oauth/token",
data={
"grant_type": "refresh_token",
"refresh_token": stored_refresh_token,
"client_id": os.environ["TABBIO_CLIENT_ID"],
"client_secret": os.environ["TABBIO_CLIENT_SECRET"],
},
)
tokens = response.json()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.
// 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
https://server.tabbio.com/oauth/revokeRFC 7009. This is what a "Disconnect Tabbio" button in your product should call.
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:
{
"data": null,
"error": { "code": "OAUTH_INVALID_TOKEN", "message": "Token is not active." },
"meta": null
}The difference is what happens next.
| Field | Type | Description |
|---|---|---|
| Expired | Recoverable | The refresh succeeds and the new access token works. Normal, hourly, invisible to the person. |
| Revoked | Not 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.
https://server.tabbio.com/oauth/introspectcurl -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.
{
"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.
{ "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.
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
scopealongside the tokens, so a feature check is a local lookup rather than a 403 the person has to see.