Tino Developers
View .md

Sign in with Tino — OAuth 2.0 and OpenID Connect

Your app signs customers in with their Tino account and calls the Tino API on their behalf through OAuth 2.0 Authorization Code + PKCE (S256), with OpenID Connect. The issuer is https://api.tino.vn; the sign-in and consent page is https://auth.tino.vn/oauth/consent.

Updated: 2026-10-27. Source of truth for endpoints, scopes and claims: the discovery document https://api.tino.vn/.well-known/openid-configuration.

Overview

Every endpoint is listed in the discovery document: read https://api.tino.vn/.well-known/openid-configuration (or /.well-known/oauth-authorization-server, same content) instead of hard-coding addresses.

ItemValue
Issuer (iss)https://api.tino.vn
Sign-in + consent page (authorization_endpoint)https://auth.tino.vn/oauth/consent
Code exchange, token refresh (token_endpoint)POST https://api.tino.vn/oauth/token
Token revocation (revocation_endpoint)POST https://api.tino.vn/oauth/revoke
UserInfo (userinfo_endpoint)GET https://api.tino.vn/oauth/userinfo
Public keys (jwks_uri)GET https://api.tino.vn/oauth/jwks
Calling the API on the customer's behalf (resource_gateway)https://api.tino.vn/v1/<route>
App registration (registration_endpoint)POST https://api.tino.vn/oauth/register — public clients register themselves, see Sign in from a CLI
Device code request (device_authorization_endpoint)POST https://api.tino.vn/oauth/device_authorization — CLIs, see Sign in from a CLI
Supported flowsresponse_type=code; grant_type = authorization_code, refresh_token, urn:ietf:params:oauth:grant-type:device_code
PKCErequired, S256 only
Client authentication at the token endpointclient_secret_post (clients with a secret) or none (public clients + PKCE); HTTP Basic is not supported
ID token signingRS256

There is no implicit flow, hybrid flow, client credentials grant or password grant.

Sign-in flow

Khách đồng ý ở auth.tino.vn; ứng dụng đổi code lấy token ở api.tino.vnauth.tino.vntrình duyệt của kháchỨng dụng của bạnserver hoặc appapi.tino.vntoken · userinfo · /v11. Chuyển tới /oauth/consentclient_id · scope · code_challenge2. Khách đăng nhập,chọn tài khoản, đồng ý3. Về redirect_uri?code=…&state=…4. POST /oauth/tokencode + code_verifier5. access_token, id_token,refresh_token6. GET /v1/<route>Bearer access_token
  1. The app redirects the customer's browser to https://auth.tino.vn/oauth/consent with client_id, scope, code_challenge.
  2. The customer signs in (if not signed in yet), chooses an account (if the browser has two or more accounts), and approves the requested scopes.
  3. Tino redirects the browser back to the app's redirect_uri with ?code=…&state=….
  4. The app calls POST https://api.tino.vn/oauth/token with code + code_verifier.
  5. Tino returns access_token, id_token (with openid), refresh_token (with offline_access).
  6. The app calls https://api.tino.vn/v1/<route> with Authorization: Bearer <access_token>.

The browser only carries the code back to the app; the app itself calls api.tino.vn to exchange the code for tokens, sending code_verifier to prove it is the same sign-in.

Registering an app

Tino creates the app for you and issues a client_id (plus a client_secret if the app has a server). Public clients (CLIs, apps running on the customer's machine, MCP clients) can also register themselves with POST https://api.tino.vn/oauth/register (RFC 7591), see Sign in from a CLI.

Client typeUse whenAuthentication at /oauth/token
Confidential (with client_secret)Web apps that exchange the code on a serverclient_id + client_secret in the form body (client_secret_post) + PKCE
Public (no secret)Apps that run on the user's device: SPA, mobile, CLIclient_id + PKCE only

When requesting an app, send Tino: the name and logo (https) shown on the consent page, the list of redirect URIs, the scopes you need (see the Calling the Tino API section), and whether the app can keep a secret on a server.

Redirect URI rules:

  • Exact string match against the registered URI (including path and query); URIs with a #fragment are not accepted.
  • Must be https://. Only for local development: http://localhost or http://127.0.0.1.
  • A loopback URI registered without a port matches any port (RFC 8252 section 7.3), which suits CLI apps that listen on a random port.

client_id can be the URL of a Client ID Metadata Document (discovery reports client_id_metadata_document_supported: true), but only URLs that Tino has registered in advance are accepted.

For each sign-in, the app generates random code_verifier, state and nonce values, keeps them (httpOnly cookie or session), then redirects the browser to https://auth.tino.vn/oauth/consent with the following parameters in the query string.

ParameterRequiredValue
response_typeyescode
client_idyesthe client_id issued by Tino
redirect_uriyesexactly the registered string
scopeyesspace-separated scopes, from the scopes granted to the app; openid to get an ID token, offline_access to get a refresh token
staterecommendedrandom string against CSRF; Tino returns it unchanged
code_challengeyesBASE64URL(SHA256(code_verifier)), without = padding
code_challenge_methodyesS256
noncerecommended (OpenID)random string of up to 255 characters; returned in the ID token
promptnonone: if the customer is signed in and has already granted all scopes, redirect back immediately without stopping at any screen

Example:

https://auth.tino.vn/oauth/consent?response_type=code&client_id=YOUR_CLIENT_ID&redirect_uri=https%3A%2F%2Fapp.example.com%2Fauth%2Fcallback&scope=openid%20profile%20email%20offline_access%20services%3Aread&state=af0ifjsldkj&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256&nonce=n-0S6_WzA2Mj

Generating PKCE (Node.js 18+):

import { createHash, randomBytes } from "node:crypto";

const codeVerifier = randomBytes(32).toString("base64url"); // 43 characters
const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url");

On the consent page, a customer who is not signed in signs in first (OTP, passkey, Google, Zalo, GitHub). If the browser has two or more Tino accounts, the customer chooses an account. If the customer has already granted these scopes to the app, they do not have to approve again; a request for more scopes asks again.

Result: Tino redirects the browser back to redirect_uri:

SituationQuery sent to redirect_uri
The customer clicks Allowcode=…&state=…
The customer clicks Denyerror=access_denied&state=…
Unknown scope or outside the app's scopeserror=invalid_scope&state=…
client_id missing or disabled, redirect_uri not registeredno redirect: a Tino page shows the error

Before using the code, compare the received state with the stored state; if they differ, discard it.

Step 2 — Exchange the code for tokens

Exchange the code within 60 seconds; each code can be used once: POST https://api.tino.vn/oauth/token, body application/x-www-form-urlencoded. Confidential clients call from the server and send client_secret in the body; do not use the Authorization: Basic header.

curl -X POST https://api.tino.vn/oauth/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d grant_type=authorization_code \
  -d code=AUTH_CODE \
  -d redirect_uri=https://app.example.com/auth/callback \
  -d client_id=YOUR_CLIENT_ID \
  -d client_secret=YOUR_CLIENT_SECRET \
  -d code_verifier=CODE_VERIFIER
ParameterValue
grant_typeauthorization_code
codethe code received at redirect_uri
redirect_urithe same URI sent in step 1
client_idyour client_id
client_secretconfidential clients only
code_verifierthe original string used to create code_challenge

Response (HTTP 200):

{
  "access_token": "tinoat_…",
  "token_type": "Bearer",
  "expires_in": 86400,
  "scope": "openid profile email offline_access services:read",
  "id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6…",
  "refresh_token": "tinort_…"
}
TokenPresent whenDefault lifetime
access_token (tinoat_…, opaque string, not a JWT)always86400 seconds (24 hours)
id_token (JWT RS256)scope includes openid86400 seconds (24 hours)
refresh_token (tinort_…)scope includes offline_access2592000 seconds (30 days)
code—60 seconds, single use

Lifetimes are configured by Tino and may change: read expires_in from each response instead of hard-coding them. Keep tokens on the server (or in the device's secure storage), never in URLs or localStorage.

Refreshing and revoking tokens

Each refresh returns a new refresh token and invalidates the old one (rotation). If a rotated refresh token is reused, Tino revokes all of the app's access to that account; the customer must sign in and approve again.

curl -X POST https://api.tino.vn/oauth/token \
  -d grant_type=refresh_token \
  -d refresh_token=tinort_… \
  -d client_id=YOUR_CLIENT_ID \
  -d client_secret=YOUR_CLIENT_SECRET
  • scope (optional): narrows the scopes of the new token; exceeding the granted scopes returns invalid_scope, and the old refresh token stays usable.
  • The response has the same shape as in step 2. Store the new refresh_token immediately, before using the access_token.
  • If several processes refresh the same refresh token, only one wins; the others count as reuse. Keep refreshing in one place (a lock per account).
  • invalid_grant on refresh: the token expired, was revoked, or the customer revoked the app's access. Send the customer through sign-in again from step 1.

Revoke when the customer signs out of the app (RFC 7009): POST https://api.tino.vn/oauth/revoke with token=<refresh_token>, client_id, client_secret (confidential clients). The refresh token and its access token both stop working; the endpoint always returns HTTP 200.

Customers can also revoke access themselves on the Authorized apps page (https://auth.tino.vn/en/me/apps); the app's tokens stop working immediately, and the next call gets 401 invalid_token.

ID token and UserInfo

The ID token is a JWT signed with RS256; verify the signature with the key whose kid matches the header, from https://api.tino.vn/oauth/jwks (cache it, reload when you see an unknown kid). Use an existing OpenID Connect library (openid-client, jose, Authlib…) instead of writing your own.

ClaimPresent whenCheck or use
issalwaysmust be https://api.tino.vn
aud, azpalwaysmust be your client_id
exp, iatalwaysnot expired
noncenonce was sent in step 1must match the stored value
auth_timewhen Tino knows itwhen the customer signed in (unix seconds)
subalwaysTino customer ID (string): the key for linking the account in your app
emailscope emailthe account's email (or the contact's email when the customer signs in with a contact account)
given_name, family_name, namescope profilegiven name, family name, full name

Match accounts by sub, not by email: customers can change their email.

UserInfo returns the same claims (sub and the claims for each scope) and needs an access token with openid:

curl https://api.tino.vn/oauth/userinfo -H "Authorization: Bearer tinoat_…"
{ "sub": "26", "email": "binh@example.com", "given_name": "Bình", "family_name": "Trần", "name": "Bình Trần" }

An access token without openid gets 403 insufficient_scope.

Calling the Tino API

The app calls the Tino API on the customer's behalf through the gateway https://api.tino.vn/v1/<route> with Authorization: Bearer <access_token>. Each route belongs to one scope; a route outside the token's scopes returns 403 insufficient_scope.

curl https://api.tino.vn/v1/details -H "Authorization: Bearer tinoat_…"

GET /v1/details runs the UserAPI route GET /details with the customer's account; the body and response keep the UserAPI format.

Scope (scope)What the customer sees on the consent pageExample routesRoutes
openidVerify your identityID token, /oauth/userinfo—
profileYour nameclaims name, given_name, family_name—
emailEmail addressclaim email—
offline_accessKeep access while you're offlineissues a refresh_token—
profile:readView account detailsGET /v1/details, GET /v1/contacts11
profile:writeEdit account detailsPUT /v1/notifications/@id/ack1
services:readView services, including service login detailsGET /v1/service, GET /v1/service/@id28
services:writeManage your servicesPOST /v1/service/@id/renew, POST /v1/service/@id/vms/@vmid/reboot14
domains:readView domainsGET /v1/domain, GET /v1/domain/@id17
domains:writeRegister, renew and manage domainsPOST /v1/domain/@id/renew, POST /v1/domain/@id/reg12
dns:readView DNS recordsGET /v1/dns, GET /v1/domain/@id/dns7
dns:writeEdit DNS recordsPOST /v1/domain/@id/dns, DELETE /v1/domain/@id/dns/@index10
billing:readView invoices and paymentsGET /v1/invoices, GET /v1/balance13
billing:writeMake payments and top upPOST /v1/addfunds7
orders:writeOrder services: cart, quotes, create orders (paying from balance also needs the Payments permission)POST /v1/quote, POST /v1/carts, POST /v1/order8
tickets:readView support ticketsGET /v1/tickets, GET /v1/tickets/@number9
tickets:writeCreate and reply to ticketsPOST /v1/tickets, POST /v1/tickets/@number4

Service scopes (the /v1 gateway opened them on 2026-10-27): call https://api.tino.vn/v1/service/<service id>/<service>/… for the customer's own services.

ScopeWhat the customer sees on the consent pageRoutesGuide
tinopay:readView TinoPay: receiving accounts, transactions, webhooks16Getting started with TinoPay
tinopay:writeManage TinoPay: virtual accounts, webhooks, notifications, Sapo integration21Getting started with TinoPay
builder:readView TinoPage Builder websites6Getting started with TinoPage Builder
builder:writeManage TinoPage Builder websites: create, publish, custom domains7Getting started with TinoPage Builder
waf:readView websites protected by TinoWAF and the event log8TinoWAF
waf:writeManage TinoWAF: websites, protection level, origin servers, IP rules11TinoWAF

tinopay:owner (create TinoPay API tokens, link receiving bank accounts) works like domains:owner: personal API tokens with an IP restriction only.

Request the fewest scopes you need: the customer sees each line on the consent page. domains:owner (get the EPP code, change the domain registrant) and domains:registrant (registrant dossier of waiting domains) are only for personal API tokens; apps are never granted them. The three order commands that can charge the balance (POST /v1/carts/{uuid}/confirm, POST /v1/order, POST /v1/order/{product_id}) need both orders:write and billing:write.

Each route, its parameters, request and response examples: Calling the Tino API.

Common errors

/oauth/token and /oauth/revoke return errors as {"error": "<code>", "error_description": "<English sentence>"}. /oauth/userinfo and the /v1 gateway add the header WWW-Authenticate: Bearer error="<code>".

EndpointHTTPerrorCauseFix
/oauth/token401invalid_clientwrong client_id or client_secret, app disabledcheck the configuration; do not send the secret via HTTP Basic
/oauth/token400invalid_grantwrong code, older than 60 seconds, already used, different redirect_uri or code_verifier; access was revokedsend the customer through sign-in again from step 1
/oauth/token (refresh)400invalid_grantrefresh token expired, revoked or reused (which also revokes access)send the customer through sign-in again
/oauth/token (refresh)400invalid_scopethe narrowed scope exceeds the granted scopesthe old refresh token stays usable
/oauth/token400unsupported_grant_typegrant_type other than authorization_code and refresh_token—
/oauth/userinfo, /v1/*401invalid_tokenthe access token is wrong, expired, or the customer revoked accessrefresh the token; if still 401, sign in again
/oauth/userinfo, /v1/*403insufficient_scopethe token lacks the route's scope (UserInfo: missing openid)request more scopes in step 1
/v1/*403ipnotallowedthe calling IP is blocked by Tinocontact Tino
consent page—access_denied (to redirect_uri)the customer clicked Denytell the customer, let them try again
consent page—invalid_scope (to redirect_uri)unknown scope or outside the app's scopesfix scope

Business errors of each /v1/* route (for example, a service that does not exist) keep the UserAPI response, usually HTTP 200 with {"error": ["<code>"]}.

References