Skip to content

OAuth2 Token Introspection — RFC 7662 Token Validation for Resource Servers

DodaTech Updated 2026-06-28 5 min read

In this tutorial, you will learn about OAuth2 Token Introspection. We cover key concepts, practical examples, and best practices to help you master this topic.

OAuth2 token introspection (RFC 7662) defines a standard endpoint where resource servers can validate tokens and retrieve their metadata, including active state, scopes, subject, and expiration, when tokens are opaque or cannot be locally validated.

What You'll Learn

  • RFC 7662 introspection endpoint implementation
  • When to use introspection vs local JWT validation
  • Token metadata returned by introspection
  • Caching introspection results for performance
  • Security considerations for introspection

Why It Matters

Not all tokens are self-contained JWTs. Opaque tokens, legacy tokens, or tokens from external identity providers require introspection. DodaTech's resource server handles both JWT (local validation) and opaque tokens (introspection) for backward compatibility with legacy integrations.

Real-World Use

An API Gateway receives requests with opaque tokens from legacy clients. It introspects each token against the authorization server, caching the result for 5 minutes. If a token is revoked mid-session, the introspection returns active: false, and the gateway rejects subsequent requests within cache TTL.

sequenceDiagram
    participant Client
    participant RS as Resource Server
    participant Auth as Authorization Server
    participant Cache as Introspection Cache

    Client->>RS: Request with opaque token
    RS->>Cache: Check cached introspection
    alt Cache hit
        Cache-->>RS: Cached result
    else Cache miss
        RS->>Auth: POST /introspect (token, client_auth)
        Auth->>Auth: Validate token
        Auth-->>RS: {active: true, scope: "read:threats", sub: "user_123"}
        RS->>Cache: Cache result (TTL: 5 min)
    end
    RS->>RS: Enforce authorization
    RS-->>Client: Response or 403

Code Examples

Example 1: Introspection Endpoint

from flask import Flask, request, jsonify
from datetime import datetime, timezone

app = Flask(__name__)

@app.route('/introspect', methods=['POST'])
def introspect_token():
    """RFC 7662 token introspection endpoint."""
    token = request.form.get('token')
    token_type_hint = request.form.get('token_type_hint', 'access_token')

    # Authenticate the resource server making the request
    client_id, client_secret = authenticate_basic_auth(request)
    if not validate_rs_client(client_id, client_secret):
        return jsonify({'error': 'invalid_client'}), 401

    # Look up the token
    token_data = find_token(token)

    if not token_data or is_expired(token_data):
        return jsonify({'active': False})

    # Return token metadata per RFC 7662
    return jsonify({
        'active': True,
        'sub': token_data['user_id'],
        'client_id': token_data.get('client_id'),
        'scope': ' '.join(token_data.get('scopes', [])),
        'token_type': 'Bearer',
        'exp': int(token_data['expires_at'].timestamp()),
        'iat': int(token_data['issued_at'].timestamp()),
        'iss': 'https://auth.dodatech.com',
        'aud': token_data.get('audience'),
        'jti': token_data.get('token_id'),
        'username': token_data.get('username'),
        'tenant_id': token_data.get('tenant_id'),
        'roles': token_data.get('roles', [])
    })

def is_expired(token_data):
    return token_data['expires_at'] < datetime.now(timezone.utc)

Example 2: Resource Server Introspection Client

import requests
from cachetools import TTLCache

class IntrospectionClient:
    def __init__(self, introspection_url, client_id, client_secret):
        self.url = introspection_url
        self.auth = (client_id, client_secret)
        self.cache = TTLCache(maxsize=10000, ttl=300)  # 5-min cache

    def introspect(self, token):
        """Introspect a token, using cache when possible."""
        token_hash = hash_token(token)

        if token_hash in self.cache:
            result = self.cache[token_hash]
            if result.get('active'):
                return result
            else:
                # Don't cache inactive results — token may be re-activated
                del self.cache[token_hash]

        # Fresh introspection
        response = requests.post(
            self.url,
            data={'token': token, 'token_type_hint': 'access_token'},
            auth=self.auth
        )
        result = response.json()

        # Cache active results
        if result.get('active'):
            self.cache[token_hash] = result

        return result

    def validate_request(self, request, required_scope=None):
        """Validate the Authorization header and return user info."""
        auth_header = request.headers.get('Authorization', '')
        if not auth_header.startswith('Bearer '):
            return None

        token = auth_header[7:]
        result = self.introspect(token)

        if not result.get('active'):
            return None

        if required_scope:
            token_scopes = result.get('scope', '').split()
            if required_scope not in token_scopes:
                return None

        return {
            'user_id': result['sub'],
            'scopes': result.get('scope', '').split(),
            'tenant_id': result.get('tenant_id'),
            'roles': result.get('roles', [])
        }

# Usage
introspector = IntrospectionClient(
    'https://auth.dodatech.com/introspect',
    'resource-server-1',
    'rs-secret-123'
)

user_info = introspector.validate_request(request, required_scope='read:threats')
if user_info:
    print(f"User {user_info['user_id']} authorized with roles: {user_info['roles']}")
else:
    print("Access denied")

Example 3: Caching with Background Refresh

import asyncio
from collections import OrderedDict
import time

class BackgroundRefreshCache:
    def __init__(self, max_size=5000, ttl=300, refresh_before=60):
        self.cache = OrderedDict()
        self.max_size = max_size
        self.ttl = ttl
        self.refresh_before = refresh_before

    def get(self, token_hash, fetcher):
        now = time.time()
        if token_hash in self.cache:
            entry = self.cache[token_hash]
            age = now - entry['cached_at']

            if age < self.ttl:
                # Move to end (most recently used)
                self.cache.move_to_end(token_hash)

                if age > (self.ttl - self.refresh_before):
                    # Trigger background refresh
                    asyncio.ensure_future(self._refresh(token_hash, fetcher))

                return entry['data']

        # Cache miss — fetch synchronously
        return self._fetch_and_cache(token_hash, fetcher, now)

    def _fetch_and_cache(self, token_hash, fetcher, now=None):
        if now is None:
            now = time.time()
        data = fetcher()
        self.cache[token_hash] = {'data': data, 'cached_at': now}
        # Evict oldest if over max size
        while len(self.cache) > self.max_size:
            self.cache.popitem(last=False)
        return data

    async def _refresh(self, token_hash, fetcher):
        """Refresh cache entry in background."""
        try:
            data = await asyncio.to_thread(fetcher)
            if data.get('active'):
                self.cache[token_hash] = {
                    'data': data,
                    'cached_at': time.time()
                }
        except Exception as e:
            print(f"Background refresh failed: {e}")

Common Mistakes

1. Not Authenticating Introspection Requests

Anyone can call your introspection endpoint. Require client credentials authentication.

2. Caching Too Long

Introspection cache TTL should be shorter than the token expiry. 5 minutes is a good default.

3. Returning Too Much Information

The introspection response should include only what resource servers need for authorization.

4. Not Handling Rate Limits

Resource servers may introspect aggressively. Use caching and implement backoff.

5. Using Introspection for JWTs

If you use JWT tokens, validate them locally. Introspection defeats the purpose of self-contained tokens.

Practice Questions

  1. When should you use token introspection instead of local JWT validation?
  2. What fields does RFC 7662 define in the introspection response?
  3. How do you cache introspection results?
  4. Why must introspection requests be authenticated?
  5. What does active: false mean in an introspection response?

Answers:

  1. When tokens are opaque (random strings), or when you need real-time revocation checks that JWTs don't support.
  2. active (required), plus optional: sub, scope, client_id, token_type, exp, iat, iss, aud, jti, username.
  3. Use an in-memory cache (Redis or local TTLCache) with a TTL shorter than token expiry.
  4. Otherwise, anyone could introspect any token, leaking user information and enabling token enumeration.
  5. The token is invalid, expired, or revoked. The resource server should reject the request.

Challenge: Build an introspection endpoint that supports both active and inactive tokens, implements client authentication, and returns full RFC 7662 metadata. Add a resource server that caches introspections with background refresh.

FAQ

Is introspection faster than local JWT validation?

: No. Introspection requires an HTTP round trip. Local JWT validation is faster but cannot detect revocation in real time.

Can I use introspection for all token types?

: Yes. Introspection works for both opaque and structured tokens (JWTs).

What is the difference between introspection and userinfo?

: Introspection validates any access token. Userinfo returns user claims using a valid token.

How do I handle introspection failures?

: Return 503 Service Unavailable. Do not assume the token is invalid — the auth server may be temporarily unreachable.

Should I cache introspections for revoked tokens?

: No. Cache only active tokens. If you cache inactive tokens, they remain blocked even after re-activation.

What's Next

Combine introspection with {{< ilink "OAuth" "OAuth2 Token Revocation" }} for full token lifecycle management, or explore {{< ilink "OAuth" "OAuth2 JWT" }} for local validation alternatives.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro