Skip to main content

Resource Indicators (RFC 8707)

Resource indicators let the client explicitly tell the authorization server which resource server(s) the issued access token is intended for. LumoAuth then mints tokens whose aud (audience) claim names those resources. Each resource server validates the aud claim before accepting the token, which prevents token-confusion attacks — a token issued for API A cannot be replayed against API B.

RFC 8707 — Resource Indicators for OAuth 2.0. Used alongside RFC 9068 — JWT Profile for OAuth 2.0 Access Tokens: the standard set of claims for JWT access tokens.

Security Enhancement

Resource indicators prevent token confusion attacks by binding access tokens to specific resource servers.

What is a Resource Indicator?

A resource indicator is an absolute URI that identifies a resource server:

GET /orgs/acme-corp/api/v1/oauth/authorize
?response_type=code
&client_id=your-client-id
&redirect_uri=https://app.example.com/callback
&scope=openid profile
&resource=https://api.example.com

Multiple Resources

Request access to multiple APIs by repeating the resource parameter:

GET /orgs/acme-corp/api/v1/oauth/authorize
?response_type=code
&client_id=your-client-id
&redirect_uri=https://app.example.com/callback
&scope=openid profile
&resource=https://api1.example.com
&resource=https://api2.example.com
&resource=https://api3.example.com

Validation

The authorization server validates each resource URI:

  1. Format validation — must be an absolute URI without fragments.
  2. Client authorization check — must be registered in the client's allowed audience URIs.
  3. Returns an invalid_target error if validation fails.
Client Registration Required

Allowed resources must be configured on the OAuth client during registration. Requesting an unregistered resource returns invalid_target.

Token Endpoint

Authorization Code Grant

When exchanging an authorization code for tokens, you can optionally specify a subset of the originally authorized resources:

curl -X POST https://app.lumoauth.dev/orgs/acme-corp/api/v1/oauth/token \
-u "CLIENT_ID:CLIENT_SECRET" \
-d "grant_type=authorization_code" \
-d "code=AUTH_CODE" \
-d "redirect_uri=https://app.example.com/callback" \
-d "resource=https://api.example.com"
Token Downscoping

Resource indicators let you request tokens scoped to specific services, reducing the blast radius if a token is compromised.

Refresh Token Grant

Refresh tokens are bound to the full set of resources from the original authorization. When using a refresh token, you can request a subset:

curl -X POST https://app.lumoauth.dev/orgs/acme-corp/api/v1/oauth/token \
-u "CLIENT_ID:CLIENT_SECRET" \
-d "grant_type=refresh_token" \
-d "refresh_token=REFRESH_TOKEN" \
-d "resource=https://api1.example.com"

Client Credentials Grant

curl -X POST https://app.lumoauth.dev/orgs/acme-corp/api/v1/oauth/token \
-u "CLIENT_ID:CLIENT_SECRET" \
-d "grant_type=client_credentials" \
-d "scope=api.read api.write" \
-d "resource=https://api.example.com"

Device Code Grant

curl -X POST https://app.lumoauth.dev/orgs/acme-corp/api/v1/oauth/token \
-u "CLIENT_ID:CLIENT_SECRET" \
-d "grant_type=urn:ietf:params:oauth:grant-type:device_code" \
-d "device_code=DEVICE_CODE" \
-d "resource=https://api.example.com"

Token Exchange Grant

Token exchange (RFC 8693 — OAuth 2.0 Token Exchange: lets a service swap one token for another, the mechanism behind delegation and impersonation) also supports resource indicators:

curl -X POST https://app.lumoauth.dev/orgs/acme-corp/api/v1/oauth/token \
-u "CLIENT_ID:CLIENT_SECRET" \
-d "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
-d "subject_token=SUBJECT_TOKEN" \
-d "subject_token_type=urn:ietf:params:oauth:token-type:access_token" \
-d "resource=https://api.example.com"

JWT Audience Claim

When resource indicators are present, the JWT access token's aud claim reflects the target resource(s):

Single Resource

{
"iss": "https://app.lumoauth.dev",
"sub": "user_123",
"aud": "https://api.example.com",
"exp": 1706817600,
"iat": 1706814000,
"client_id": "mobile_app",
"scope": "openid profile"
}

Multiple Resources

{
"iss": "https://app.lumoauth.dev",
"sub": "user_123",
"aud": [
"https://api1.example.com",
"https://api2.example.com",
"https://api3.example.com"
],
"exp": 1706817600,
"iat": 1706814000,
"client_id": "mobile_app",
"scope": "openid profile"
}

No Resources (Default)

{
"iss": "https://app.lumoauth.dev",
"sub": "user_123",
"aud": "mobile_app",
"exp": 1706817600,
"iat": 1706814000,
"client_id": "mobile_app",
"scope": "openid profile"
}

Without resource indicators, the aud defaults to the client_id.

Pushed Authorization Request (PAR)

Resource indicators can be included in PAR requests (RFC 9126 — Pushed Authorization Requests: the client POSTs the authorization parameters to a back-channel endpoint first, receiving a request_uri that it then uses in the front-channel redirect):

curl -X POST https://app.lumoauth.dev/orgs/acme-corp/api/v1/oauth/par \
-u "CLIENT_ID:CLIENT_SECRET" \
-d "response_type=code" \
-d "redirect_uri=https://app.example.com/callback" \
-d "scope=openid profile" \
-d "resource=https://api.example.com" \
-d "resource=https://api2.example.com"

Resources are validated at PAR time and stored with the request. When using the returned request_uri, the authorization endpoint automatically loads the resources.

Token Introspection

The introspection endpoint returns the aud claim for resource-constrained tokens:

Single Resource

{
"active": true,
"scope": "openid profile",
"client_id": "mobile_app",
"aud": "https://api.example.com",
"exp": 1706817600,
"sub": "user_123"
}

Multiple Resources

{
"active": true,
"scope": "openid profile",
"client_id": "mobile_app",
"aud": [
"https://api.example.com",
"https://api2.example.com"
],
"exp": 1706817600,
"sub": "user_123"
}

Resource Server Validation

Critical Security Requirement

Resource servers MUST validate that the aud claim in access tokens matches their own resource URI. Accepting tokens with the wrong audience is a security vulnerability.

Example Validation (PHP)

$jwt = decode($accessToken);
$expectedAudience = 'https://api.example.com';

if (is_string($jwt['aud'])) {
if ($jwt['aud'] !== $expectedAudience) {
throw new InvalidAudienceException();
}
} elseif (is_array($jwt['aud'])) {
if (!in_array($expectedAudience, $jwt['aud'])) {
throw new InvalidAudienceException();
}
} else {
throw new InvalidAudienceException();
}

Example Validation (Node.js)

const jwt = decode(accessToken);
const expectedAudience = 'https://api.example.com';

const audiences = Array.isArray(jwt.aud) ? jwt.aud : [jwt.aud];

if (!audiences.includes(expectedAudience)) {
throw new Error('Invalid audience');
}

Client Configuration

Organization Portal

  1. Navigate to Applications.
  2. Select your application.
  3. Go to the Configuration tab.
  4. Scroll to Resource Indicators / Audience URIs.
  5. Add your allowed resource URIs (one per line).

Dynamic Client Registration

curl -X POST https://app.lumoauth.dev/orgs/acme-corp/api/v1/oidc/register \
-H "Content-Type: application/json" \
-d '{
"client_name": "My Application",
"redirect_uris": ["https://app.example.com/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"audience_uris": [
"https://api.example.com",
"https://api2.example.com"
]
}'

Validation Modes

ModeDescriptionExample
Exact MatchResource must exactly match a registered URIRegistered: https://api.example.com ✓ Valid: https://api.example.com ✗ Invalid: https://api.example.com/v2
Prefix MatchResource can be a path under a registered URIRegistered: https://api.example.com ✓ Valid: https://api.example.com/v2 ✓ Valid: https://api.example.com/path/to/resource ✗ Invalid: https://api2.example.com

Error Responses

invalid_target

Returned when:

  • The resource URI is malformed.
  • The resource is not registered for the client.
  • The requested resources are not a subset of granted resources.
{
"error": "invalid_target",
"error_description": "Resource URI must be an absolute URI without fragment"
}
{
"error": "invalid_target",
"error_description": "Resource 'https://api.example.com' is not registered for this client"
}
{
"error": "invalid_target",
"error_description": "Requested resources must be a subset of granted resources"
}

Discovery Metadata

The OIDC discovery document advertises RFC 8707 support:

curl https://app.lumoauth.dev/.well-known/openid-configuration
{
"issuer": "https://app.lumoauth.dev/orgs/acme",
"authorization_endpoint": "...",
"token_endpoint": "...",
"resource_indicators_supported": true
}

Security Benefits

Token Replay Prevention

Without resource indicators, a token stolen from one API can be replayed against other APIs. With resource indicators:

  • Each token is bound to specific resource server(s) via the aud claim.
  • Resource servers validate the audience before accepting tokens.
  • A compromised API cannot use stolen tokens elsewhere.

Least Privilege

Authorize broadly once, then obtain narrow tokens for each API:

User authorizes: [billing-api, users-api, analytics-api]

Token 1 (for billing): aud = "https://billing-api.example.com"
Token 2 (for users): aud = "https://users-api.example.com"
Token 3 (for analytics): aud = "https://analytics-api.example.com"

Centralized Governance

The authorization server controls which clients can access which resources:

  • Pre-register allowed audience URIs per client.
  • Prevent unauthorized cross-API access.
  • Keep a centralized audit trail.

Best Practices

PracticeRecommendation
Use HTTPSAlways use https:// URIs in production
Validate audienceResource servers MUST check the aud claim on every request
Request minimal resourcesOnly request access to APIs needed for the current operation
Use token downscopingAuthorize broadly, request narrowly at the token endpoint
Register URIs carefullyOnly register legitimate resource servers in client config

References