OAuth 2.1 walkthrough
OAuth + DCR + DPoP in three minutes
Every tool also answers without signing in, with none of this. OAuth 2.1 is the path to limits counted per user + request attribution — and it’s self-serve via Dynamic Client Registration (RFC 7591).
This page is for apps where a person signs in, with their AVnester mobile number, and authorises. A backend calling on its own, with no one signed in, uses a server credential instead (an API key, or OAuth client credentials once they are enabled).
Step 1
Register a client via DCR (RFC 7591)
POST your client metadata to the registration endpoint. No human approval, no email thread. You get a client_id instantly. Use token_endpoint_auth_method: "none" — end-user OAuth 2.1 with PKCE doesn’t need a client secret, because the person who signs in is the proof. A server calling with no one signed in is a different job: it uses a server credential, created in the developer console rather than registered here.
curl -X POST https://auth.avnester.com/oauth/register \
-H 'Content-Type: application/json' \
-d '{
"client_name": "My LLM Agent",
"redirect_uris": ["https://my-app.example.com/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none"
}'{
"client_id": "av_dcr_8f3d2c19a4b6e7d5",
"client_id_issued_at": 1716700800,
"registration_access_token": "...",
"registration_client_uri": "https://auth.avnester.com/oauth/register/av_dcr_8f3d2c19a4b6e7d5",
"grant_types": ["authorization_code", "refresh_token"],
"redirect_uris": ["https://my-app.example.com/callback"],
"token_endpoint_auth_method": "none"
}Step 2
Run the authorization-code-with-PKCE flow
- Generate a PKCE
code_verifier(~64 random URL-safe chars) and the matchingcode_challenge = SHA-256(verifier). - Redirect the user to
https://auth.avnester.com/oauth/authorize?response_type=code&client_id=...&redirect_uri=...&code_challenge=...&code_challenge_method=S256&scope=calculators:use%20listings:read:public%20locality:read:public - The person signs in with their AVnester mobile number: a one-time code on avnester.com, and a new number gets a free account. They then approve your app on the consent screen.
- After consent, the user is redirected to your
redirect_uriwith?code=.... - Exchange that code at the token endpoint for an access token + refresh token. DPoP is an optional uplift — see Step 3 to sender-constrain your token; plain bearer tokens work too.
Store the refresh token each refresh returns and discard the old one. A public client that is not DPoP-bound gets a new refresh token on every refresh, except one whose registered redirect URIs are all loopback (localhost, 127.0.0.1 or [::1]), which keeps a stable one that expires 30 days after sign-in (only a rotating token runs on, up to a year in all). Reusing a replaced refresh token fails and revokes the grant, so the person has to sign in again.
Discovery metadata (issuer, endpoints, supported scopes, JWKS URI) is at https://auth.avnester.com/.well-known/oauth-authorization-server (RFC 8414).
Step 3
Generate DPoP proofs and call tools
DPoP (RFC 9449) is optional but recommended for production. It binds the access token to a keypair you generate: every request carries a fresh DPoP proof JWT signed with that key — a stolen access token can’t be replayed from a different machine. Reuse the same keypair across requests; rotate annually.
Node.js (jose)
import { generateKeyPair, exportJWK, SignJWT } from 'jose';
import crypto from 'node:crypto';
// 1. Generate a DPoP keypair once per client (persist across requests).
const { privateKey, publicKey } = await generateKeyPair('ES256');
const publicJwk = await exportJWK(publicKey);
// 2. Build a DPoP proof JWT for each request.
async function dpopProof(method, url, accessToken) {
const headers = {
alg: 'ES256',
typ: 'dpop+jwt',
jwk: publicJwk,
};
const payload = {
htm: method,
htu: url,
iat: Math.floor(Date.now() / 1000),
jti: crypto.randomUUID(),
};
if (accessToken) {
payload.ath = crypto
.createHash('sha256')
.update(accessToken)
.digest('base64url');
}
return await new SignJWT(payload)
.setProtectedHeader(headers)
.sign(privateKey);
}
// 3. Token exchange — DPoP header on the token endpoint too.
const url = 'https://auth.avnester.com/oauth/token';
const proof = await dpopProof('POST', url);
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'DPoP': proof,
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code: AUTH_CODE,
code_verifier: PKCE_VERIFIER,
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
}),
});
const tokens = await res.json(); // { access_token, refresh_token, ... }
// 4. Call a tool — fresh DPoP proof per request, bound to the access token.
const toolUrl = 'https://api.avnester.com/public/v1/search_properties';
const toolProof = await dpopProof('POST', toolUrl, tokens.access_token);
await fetch(toolUrl, {
method: 'POST',
headers: {
'Authorization': `DPoP ${tokens.access_token}`,
'DPoP': toolProof,
'Content-Type': 'application/json',
},
body: JSON.stringify({ city: 'Coimbatore' }),
});Python (Authlib + cryptography)
from authlib.jose import jwt
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives import serialization
import hashlib, base64, time, uuid, requests
# 1. Generate DPoP keypair once.
private_key = ec.generate_private_key(ec.SECP256R1())
public_jwk = { # serialize public key as JWK
"kty": "EC", "crv": "P-256",
"x": base64.urlsafe_b64encode(
private_key.public_key().public_numbers().x.to_bytes(32, "big")
).rstrip(b"=").decode(),
"y": base64.urlsafe_b64encode(
private_key.public_key().public_numbers().y.to_bytes(32, "big")
).rstrip(b"=").decode(),
}
# 2. Build a DPoP proof per request.
def dpop_proof(method, url, access_token=None):
payload = {
"htm": method, "htu": url,
"iat": int(time.time()), "jti": str(uuid.uuid4()),
}
if access_token:
ath = hashlib.sha256(access_token.encode()).digest()
payload["ath"] = base64.urlsafe_b64encode(ath).rstrip(b"=").decode()
header = {"alg": "ES256", "typ": "dpop+jwt", "jwk": public_jwk}
return jwt.encode(header, payload, private_key).decode()
# 3. Token exchange.
token_url = "https://auth.avnester.com/oauth/token"
r = requests.post(
token_url,
headers={"DPoP": dpop_proof("POST", token_url)},
data={
"grant_type": "authorization_code",
"code": AUTH_CODE, "code_verifier": PKCE_VERIFIER,
"client_id": CLIENT_ID, "redirect_uri": REDIRECT_URI,
},
)
tokens = r.json()
# 4. Call a tool.
tool_url = "https://api.avnester.com/public/v1/search_properties"
requests.post(
tool_url,
headers={
"Authorization": f"DPoP {tokens['access_token']}",
"DPoP": dpop_proof("POST", tool_url, tokens["access_token"]),
},
json={"city": "Coimbatore"},
)Go (sketch)
// Sketch only — see github.com/lestrrat-go/jwx for production-grade DPoP.
package main
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"encoding/json"
"net/http"
"strings"
)
// 1. Generate ES256 keypair once.
privKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
// 2. Build dpop_proof(method, url, accessToken) returning a JWT signed with privKey.
// Use github.com/lestrrat-go/jwx/jwt + jws.
// 3. Token exchange.
form := strings.NewReader(`grant_type=authorization_code&code=...&code_verifier=...&client_id=...`)
req, _ := http.NewRequest("POST", "https://auth.avnester.com/oauth/token", form)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("DPoP", dpopProof("POST", req.URL.String(), ""))
resp, _ := http.DefaultClient.Do(req)
var tokens map[string]any
json.NewDecoder(resp.Body).Decode(&tokens)
// 4. Tool call.
toolReq, _ := http.NewRequest("POST", "https://api.avnester.com/public/v1/search_properties",
strings.NewReader(`{"city":"Coimbatore"}`))
toolReq.Header.Set("Content-Type", "application/json")
toolReq.Header.Set("Authorization", "DPoP "+tokens["access_token"].(string))
toolReq.Header.Set("DPoP", dpopProof("POST", toolReq.URL.String(), tokens["access_token"].(string)))
http.DefaultClient.Do(toolReq)What OAuth gets you
The Developer tier (and what's coming)
- 30 requests/min per tool, counted per user — the same ceiling on every tool, with no limit across tools or on uncached queries; the per-IP limits on a call made without signing in do not apply.
- 1,000 calls a month, 100 a day — free. Usage headers on every response tell you where you are, so you can pace against the cap rather than discover it.
- Request attribution — your
client_idappears in our abuse logs so we can reach out before rate-limiting you for spikes. - Usage analytics — per-app requests, success rate, p95 and rate-limit share in the developer console (window by tier).
- Business, Scale and Enterprise are contact-sales — the console shows your allowance and links you to sales. Your OAuth client is what gets upgraded: no code changes; the next access token after upgrade carries
tier: "business"automatically. A licensed app can also hold server credentials for calls your backend makes on its own.