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.
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:
- Format validation — must be an absolute URI without fragments.
- Client authorization check — must be registered in the client's allowed audience URIs.
- Returns an
invalid_targeterror if validation fails.
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"
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
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
- Navigate to Applications.
- Select your application.
- Go to the Configuration tab.
- Scroll to Resource Indicators / Audience URIs.
- 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
| Mode | Description | Example |
|---|---|---|
| Exact Match | Resource must exactly match a registered URI | Registered: https://api.example.com ✓ Valid: https://api.example.com ✗ Invalid: https://api.example.com/v2 |
| Prefix Match | Resource can be a path under a registered URI | Registered: 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
audclaim. - 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
| Practice | Recommendation |
|---|---|
| Use HTTPS | Always use https:// URIs in production |
| Validate audience | Resource servers MUST check the aud claim on every request |
| Request minimal resources | Only request access to APIs needed for the current operation |
| Use token downscoping | Authorize broadly, request narrowly at the token endpoint |
| Register URIs carefully | Only register legitimate resource servers in client config |
References
- RFC 8707: Resource Indicators for OAuth 2.0
- RFC 9068: JWT Profile for OAuth 2.0 Access Tokens
- RFC 7662: OAuth 2.0 Token Introspection
- RFC 9126: OAuth 2.0 Pushed Authorization Requests