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.
| Item | Value |
|---|---|
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 flows | response_type=code; grant_type = authorization_code, refresh_token, urn:ietf:params:oauth:grant-type:device_code |
| PKCE | required, S256 only |
| Client authentication at the token endpoint | client_secret_post (clients with a secret) or none (public clients + PKCE); HTTP Basic is not supported |
| ID token signing | RS256 |
There is no implicit flow, hybrid flow, client credentials grant or password grant.
Sign-in flow
- The app redirects the customer's browser to
https://auth.tino.vn/oauth/consentwithclient_id,scope,code_challenge. - 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.
- Tino redirects the browser back to the app's
redirect_uriwith?code=…&state=…. - The app calls
POST https://api.tino.vn/oauth/tokenwithcode+code_verifier. - Tino returns
access_token,id_token(withopenid),refresh_token(withoffline_access). - The app calls
https://api.tino.vn/v1/<route>withAuthorization: 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 type | Use when | Authentication at /oauth/token |
|---|---|---|
Confidential (with client_secret) | Web apps that exchange the code on a server | client_id + client_secret in the form body (client_secret_post) + PKCE |
| Public (no secret) | Apps that run on the user's device: SPA, mobile, CLI | client_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
#fragmentare not accepted. - Must be
https://. Only for local development:http://localhostorhttp://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.
Step 1 — Send the customer to the consent page
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.
| Parameter | Required | Value |
|---|---|---|
response_type | yes | code |
client_id | yes | the client_id issued by Tino |
redirect_uri | yes | exactly the registered string |
scope | yes | space-separated scopes, from the scopes granted to the app; openid to get an ID token, offline_access to get a refresh token |
state | recommended | random string against CSRF; Tino returns it unchanged |
code_challenge | yes | BASE64URL(SHA256(code_verifier)), without = padding |
code_challenge_method | yes | S256 |
nonce | recommended (OpenID) | random string of up to 255 characters; returned in the ID token |
prompt | no | none: 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_WzA2MjGenerating 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:
| Situation | Query sent to redirect_uri |
|---|---|
| The customer clicks Allow | code=…&state=… |
| The customer clicks Deny | error=access_denied&state=… |
Unknown scope or outside the app's scopes | error=invalid_scope&state=… |
client_id missing or disabled, redirect_uri not registered | no 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| Parameter | Value |
|---|---|
grant_type | authorization_code |
code | the code received at redirect_uri |
redirect_uri | the same URI sent in step 1 |
client_id | your client_id |
client_secret | confidential clients only |
code_verifier | the 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_…"
}| Token | Present when | Default lifetime |
|---|---|---|
access_token (tinoat_…, opaque string, not a JWT) | always | 86400 seconds (24 hours) |
id_token (JWT RS256) | scope includes openid | 86400 seconds (24 hours) |
refresh_token (tinort_…) | scope includes offline_access | 2592000 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_SECRETscope(optional): narrows the scopes of the new token; exceeding the granted scopes returnsinvalid_scope, and the old refresh token stays usable.- The response has the same shape as in step 2. Store the new
refresh_tokenimmediately, before using theaccess_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_granton 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.
| Claim | Present when | Check or use |
|---|---|---|
iss | always | must be https://api.tino.vn |
aud, azp | always | must be your client_id |
exp, iat | always | not expired |
nonce | nonce was sent in step 1 | must match the stored value |
auth_time | when Tino knows it | when the customer signed in (unix seconds) |
sub | always | Tino customer ID (string): the key for linking the account in your app |
email | scope email | the account's email (or the contact's email when the customer signs in with a contact account) |
given_name, family_name, name | scope profile | given 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 page | Example routes | Routes |
|---|---|---|---|
openid | Verify your identity | ID token, /oauth/userinfo | — |
profile | Your name | claims name, given_name, family_name | — |
email | Email address | claim email | — |
offline_access | Keep access while you're offline | issues a refresh_token | — |
profile:read | View account details | GET /v1/details, GET /v1/contacts | 11 |
profile:write | Edit account details | PUT /v1/notifications/@id/ack | 1 |
services:read | View services, including service login details | GET /v1/service, GET /v1/service/@id | 28 |
services:write | Manage your services | POST /v1/service/@id/renew, POST /v1/service/@id/vms/@vmid/reboot | 14 |
domains:read | View domains | GET /v1/domain, GET /v1/domain/@id | 17 |
domains:write | Register, renew and manage domains | POST /v1/domain/@id/renew, POST /v1/domain/@id/reg | 12 |
dns:read | View DNS records | GET /v1/dns, GET /v1/domain/@id/dns | 7 |
dns:write | Edit DNS records | POST /v1/domain/@id/dns, DELETE /v1/domain/@id/dns/@index | 10 |
billing:read | View invoices and payments | GET /v1/invoices, GET /v1/balance | 13 |
billing:write | Make payments and top up | POST /v1/addfunds | 7 |
orders:write | Order services: cart, quotes, create orders (paying from balance also needs the Payments permission) | POST /v1/quote, POST /v1/carts, POST /v1/order | 8 |
tickets:read | View support tickets | GET /v1/tickets, GET /v1/tickets/@number | 9 |
tickets:write | Create and reply to tickets | POST /v1/tickets, POST /v1/tickets/@number | 4 |
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.
| Scope | What the customer sees on the consent page | Routes | Guide |
|---|---|---|---|
tinopay:read | View TinoPay: receiving accounts, transactions, webhooks | 16 | Getting started with TinoPay |
tinopay:write | Manage TinoPay: virtual accounts, webhooks, notifications, Sapo integration | 21 | Getting started with TinoPay |
builder:read | View TinoPage Builder websites | 6 | Getting started with TinoPage Builder |
builder:write | Manage TinoPage Builder websites: create, publish, custom domains | 7 | Getting started with TinoPage Builder |
waf:read | View websites protected by TinoWAF and the event log | 8 | TinoWAF |
waf:write | Manage TinoWAF: websites, protection level, origin servers, IP rules | 11 | TinoWAF |
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>".
| Endpoint | HTTP | error | Cause | Fix |
|---|---|---|---|---|
/oauth/token | 401 | invalid_client | wrong client_id or client_secret, app disabled | check the configuration; do not send the secret via HTTP Basic |
/oauth/token | 400 | invalid_grant | wrong code, older than 60 seconds, already used, different redirect_uri or code_verifier; access was revoked | send the customer through sign-in again from step 1 |
/oauth/token (refresh) | 400 | invalid_grant | refresh token expired, revoked or reused (which also revokes access) | send the customer through sign-in again |
/oauth/token (refresh) | 400 | invalid_scope | the narrowed scope exceeds the granted scopes | the old refresh token stays usable |
/oauth/token | 400 | unsupported_grant_type | grant_type other than authorization_code and refresh_token | — |
/oauth/userinfo, /v1/* | 401 | invalid_token | the access token is wrong, expired, or the customer revoked access | refresh the token; if still 401, sign in again |
/oauth/userinfo, /v1/* | 403 | insufficient_scope | the token lacks the route's scope (UserInfo: missing openid) | request more scopes in step 1 |
/v1/* | 403 | ipnotallowed | the calling IP is blocked by Tino | contact Tino |
| consent page | — | access_denied (to redirect_uri) | the customer clicked Deny | tell the customer, let them try again |
| consent page | — | invalid_scope (to redirect_uri) | unknown scope or outside the app's scopes | fix 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
- Tino OpenID discovery: endpoints, scopes, claims running in production
- Protected resource metadata (RFC 9728): for MCP clients; see Connect Claude, ChatGPT to Tino (MCP)
- JWKS: public keys for verifying ID tokens
- RFC 6749 OAuth 2.0
- RFC 7636 PKCE
- RFC 7009 token revocation
- RFC 8252 native apps
- OpenID Connect Core 1.0