Authorization flow (Authorization Code + PKCE)
This page describes the full cycle — from the redirect to receiving and refreshing tokens. An OIDC library does most of these steps for you; what is written here is what you need to know to configure it and to handle errors.
Step 1 — Generate a PKCE pair
For every sign-in, generate a fresh random code_verifier (43–128 characters, [A-Za-z0-9-._~]) and compute code_challenge = BASE64URL(SHA256(code_verifier)). The verifier stays with you (in the session); the challenge goes in the authorize request.
S256 method is supported — a plain challenge, or none at all, is refused with invalid_request.
Step 3 — Back on your redirect_uri
On success the customer returns to {redirect_uri}?code=…&state=…. Check state first, then exchange the code. The code is single-use and short-lived (a few minutes) — exchange it immediately.
On failure the same address receives ?error=…&error_description=…&state=…:
| error | When | What to do |
|---|---|---|
| access_denied | The customer pressed "back" / cancelled the sign-in. | Return them to your sign-in page without showing an error. |
| login_required | prompt=none and the browser has no active INEX ID session (or it is older than max_age). |
Start a normal (interactive) sign-in. |
| account_selection_required | prompt=none and several accounts are signed in in the browser. |
Start a sign-in with prompt=select_account. |
| invalid_request | A parameter is missing or wrong (e.g. PKCE, a method other than S256). | Fix the request — this is an error on your side. |
| invalid_scope | A requested scope does not exist. | Use only the documented scopes. |
client_id or the redirect_uri is wrong, INEX ID does not send the customer back to you, for security reasons, and shows the error on its own page.
Step 4 — Exchange the code for tokens
{issuer}/oauth/token
From your server, send POST {issuer}/oauth/token with Content-Type: application/x-www-form-urlencoded. A confidential client authenticates with its client_secret — either in the body (client_secret_post) or with an HTTP Basic header (client_secret_basic). A public client sends no secret.
| Field | Required | Description |
|---|---|---|
| grant_type | Yes | authorization_code. |
| code | Yes | The code received on the callback. |
| redirect_uri | Yes | Exactly the one used in the authorize request. |
| client_id | Yes | Your client identifier. |
| client_secret | Conditional | Confidential clients only (unless you use the Basic header). |
| code_verifier | Yes | The verifier stored in step 1. |
Response 200 OK
| Field | Type | Description |
|---|---|---|
| token_type | string | Always Bearer. |
| expires_in | integer | Access token lifetime in seconds. Use this value — do not hard-code a lifetime. |
| access_token | string | Token for calling /userinfo. |
| refresh_token | string | For obtaining a new access token (see step 6). Keep it server-side only, encrypted. |
| id_token | string (JWT) | Signed JWT carrying the customer's identity. Returned when the scope contained openid. |
{"error": "…", "error_description": "…"}. Most common: invalid_grant (the code expired, was already used, or the verifier / redirect_uri does not match), invalid_client (wrong secret), unsupported_grant_type (only authorization_code and refresh_token are supported).
Step 5 — Verify the id_token
The id_token is a JWT signed with RS256. Before trusting it, always check:
-
The signature — with the public key from
{issuer}/oauth/jwks, chosen by the header'skid. Cache the JWKS and download it again when an unknownkidappears (key rotation). -
iss— equals the issuer exactly (e.g.https://id.inex.ge, no trailing/). -
aud— equals yourclient_id. -
exp— the token has not expired (an id_token is valid for 1 hour). -
nonce— equals the one sent in the authorize request.
sub
sub is the customer's permanent identifier (a UUID). It never changes and is the same for every client. That is what you store and use to identify the customer — not the email or phone, which can change (and a phone number is not even unique).
Step 6 — Refresh the token
When the access token expires, send POST {issuer}/oauth/token with grant_type=refresh_token, refresh_token, client_id (+ client_secret for a confidential client). The response has the same shape: a new access token, a new refresh token and a new id_token (without nonce/auth_time).
invalid_grant. If you make parallel requests for the same customer, guard the refresh with a lock so two processes never use the same refresh token at once.
Silent check (prompt=none)
To sign a customer in automatically when they are already signed in to INEX ID (e.g. arriving from another INEX service), send the normal authorize request with prompt=none. It returns at once with either a code or a login_required / account_selection_required error — the customer never sees a form.
import crypto from 'node:crypto';
const codeVerifier = crypto.randomBytes(32).toString('base64url');
const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url');
const state = crypto.randomBytes(16).toString('base64url');
const nonce = crypto.randomBytes(16).toString('base64url');
// keep codeVerifier, state and nonce in the user's session
$codeVerifier = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=');
$codeChallenge = rtrim(strtr(base64_encode(hash('sha256', $codeVerifier, true)), '+/', '-_'), '=');
$state = bin2hex(random_bytes(16));
$nonce = bin2hex(random_bytes(16));
GET https://id-staging.inex.ge/oauth/authorize
?response_type=code
&client_id=YOUR_CLIENT_ID
&redirect_uri=https%3A%2F%2Fshop.example.com%2Fauth%2Finex%2Fcallback
&scope=openid%20profile%20email%20phone
&state=STATE
&nonce=NONCE
&code_challenge=CODE_CHALLENGE
&code_challenge_method=S256
&ui_locales=ka
GET https://shop.example.com/auth/inex/callback?code=def50200a1...&state=STATE
GET https://shop.example.com/auth/inex/callback?error=access_denied&error_description=The+customer+cancelled+the+sign-in.&state=STATE
curl -X POST https://id-staging.inex.ge/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d grant_type=authorization_code \
-d code=def50200a1... \
-d redirect_uri=https://shop.example.com/auth/inex/callback \
-d client_id=YOUR_CLIENT_ID \
-d client_secret=YOUR_CLIENT_SECRET \
-d code_verifier=CODE_VERIFIER
{
"token_type": "Bearer",
"expires_in": 86400,
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9...",
"refresh_token": "def50200b7f3...",
"id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6Ii4uLiJ9..."
}
{
"iss": "https://id-staging.inex.ge",
"aud": "YOUR_CLIENT_ID",
"sub": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
"iat": 1760961600,
"exp": 1760965200,
"auth_time": 1760961590,
"nonce": "NONCE",
"given_name": "Nino",
"family_name": "Beridze",
"name": "Nino Beridze",
"locale": "ka",
"email": "nino@example.com",
"email_verified": true,
"phone_number": "995599123456",
"phone_number_verified": true
}
import { createRemoteJWKSet, jwtVerify } from 'jose';
const JWKS = createRemoteJWKSet(new URL('https://id-staging.inex.ge/oauth/jwks'));
const { payload } = await jwtVerify(idToken, JWKS, {
issuer: 'https://id-staging.inex.ge',
audience: 'YOUR_CLIENT_ID',
});
if (payload.nonce !== session.nonce) throw new Error('nonce mismatch');
const inexUserId = payload.sub; // store it with your user
curl -X POST https://id-staging.inex.ge/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d grant_type=refresh_token \
-d refresh_token=def50200b7f3... \
-d client_id=YOUR_CLIENT_ID \
-d client_secret=YOUR_CLIENT_SECRET