Guides and Tutorials/Inbound Apps

Developing APIs with OAuth (Inbound Apps)

To fully leverage Inbound Apps, your APIs should be designed to enforce OAuth scopes and permissions effectively. This ensures secure and granular access control, allowing AI agents, partner applications, and users to interact with your APIs while respecting consented permissions.

Designing an OAuth-specific API

Below is a generic OpenAPI spec for constructing an API that integrates Descope as an OAuth provider, manages user consent and scopes, and enforces general authorization using OAuth tokens.

This OpenAPI spec:

  • Defines an authentication mechanism using Descope as an OAuth Provider with Inbound Apps.
  • Includes endpoints that validate and enforce scopes.
  • Supports role-based authorization with OAuth scopes.
  • Implements OAuth 2.0 Bearer Token Authentication.

OpenAPI 3.0 Specification (YAML)

You can test this API spec with Swagger here.

openapi: 3.0.3
info:
  title: Example API with Descope Inbound Apps
  description: |
    This API uses Descope as an OAuth provider to authenticate users, manage consent and scopes, and enforce authorization for API access.
  version: 1.0.0
 
servers:
  - url: https://api.yourservice.com
    description: Production Server
 
components:
  securitySchemes:
    OAuth2:
      type: oauth2
      description: "Use Descope as an OAuth 2.0 provider for authentication."
      flows:
        authorizationCode:
          authorizationUrl: https://api.descope.com/oauth2/v1/apps/authorize
          tokenUrl: https://api.descope.com/oauth2/v1/apps/token
          scopes:
            contacts.read: "Read user's contacts"
            contacts.write: "Modify user's contacts"
            profile: "Access user's basic profile information"
            admin: "Full administrative access"
 
  schemas:
    ErrorResponse:
      type: object
      properties:
        error:
          type: string
          description: Error message explaining why the request failed.
 
security:
  - OAuth2: []  # Require authentication by default
 
paths:
  /user/profile:
    get:
      summary: Get User Profile
      description: Retrieve authenticated user's profile information.
      operationId: getUserProfile
      security:
        - OAuth2: [profile]
      responses:
        "200":
          description: Successfully retrieved user profile.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: string
                  name:
                    type: string
                  email:
                    type: string
        "401":
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
 
  /contacts:
    get:
      summary: List User's Contacts
      description: Retrieve a list of contacts for the authenticated user.
      operationId: getContacts
      security:
        - OAuth2: [contacts.read]
      responses:
        "200":
          description: Successfully retrieved contacts.
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    id:
                      type: string
                    name:
                      type: string
                    email:
                      type: string
        "401":
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "403":
          description: Forbidden - Missing required scope
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
 
    post:
      summary: Add a New Contact
      description: Create a new contact for the authenticated user.
      operationId: createContact
      security:
        - OAuth2: [contacts.write]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                email:
                  type: string
      responses:
        "201":
          description: Contact successfully created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: string
                  name:
                    type: string
                  email:
                    type: string
        "401":
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "403":
          description: Forbidden - Missing required scope
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
 
  /admin/users:
    get:
      summary: Get All Users (Admin Only)
      description: Retrieve a list of all users in the system. Requires admin scope.
      operationId: getAllUsers
      security:
        - OAuth2: [admin]
      responses:
        "200":
          description: Successfully retrieved users.
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    id:
                      type: string
                    name:
                      type: string
                    email:
                      type: string
        "401":
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "403":
          description: Forbidden - Requires admin scope
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

How This OpenAPI Spec Works

  1. OAuth2 Authentication

    • Uses Descope as an OAuth provider with an authorization code flow.
    • Enforces OAuth scopes at the API level.
  2. Authorization Enforcement

    • User Profile (profile scope) → Required for retrieving user details.
    • Contacts (contacts.read, contacts.write scopes) → Controls read/write access.
    • Admin Actions (admin scope) → Only users with the admin scope can access system-wide data.
  3. Consent-Driven Access

    • Users must grant consent before third-party applications can access their data.

Best Practices for Securing OAuth-based APIs

When developing APIs that rely on OAuth for authentication and authorization, consider the following best practices to ensure secure and compliant access control.

1. Enforce Scope-Based Access Control

Scopes define what an application can do on behalf of a user. When an inbound app requests an access token, the API must verify that the token includes the required scopes for the requested operation.

Scope enforcement can be handled in two ways:

  • At the API level - Using middleware within your application to validate scopes before processing requests. See the example below.
  • At the API Gateway level - Many API gateways natively support JWT validation and can enforce scopes before requests reach your backend.

For more details on using API gateways to validate Descope tokens, see our OIDC JWT authorizers documentation.

Example: Validating Scopes in an API Request (FastAPI)

This example shows how you would typically enforce OAuth scopes in a FastAPI application using Descope JWTs.

from fastapi import FastAPI, Depends, HTTPException, Header
from descope import DescopeClient
from descope.exceptions import DescopeException
 
# Initialize Descope client
DESCOPE_PROJECT_ID = "your-project-id"
descope_client = DescopeClient(project_id=DESCOPE_PROJECT_ID)
 
app = FastAPI()
 
# Function to validate session and extract scopes
def get_token_scopes(authorization: str = Header(None)):
    if not authorization or not authorization.startswith("Bearer "):
        raise HTTPException(status_code=401, detail="Missing or invalid Authorization header")
 
    token = authorization.split("Bearer ")[1]
    try:
        # Validate the session with Descope
        session_data = descope_client.validate_session(token)
        return session_data.get("claims", {}).get("scope", "").split()
    except DescopeException:
        raise HTTPException(status_code=401, detail="Invalid or expired token")
 
# Function to enforce required scopes
def require_scope(required_scope: str):
    def check_scope(token_scopes: list = Depends(get_token_scopes)):
        if required_scope not in token_scopes:
            raise HTTPException(status_code=403, detail="Insufficient permissions")
    return check_scope
 
# Protected API endpoint
@app.get("/contacts", dependencies=[Depends(require_scope("contacts.read"))])
async def get_contacts():
    return {"message": "Returning user's contacts"}

This ensures that only tokens with the contacts.read scope can call the /contacts API.

Example: Conditional Data Filtering Based on Scopes

Instead of blocking access entirely (as in scope validation), you can also use fine-grained filtering to adjust the response based on the user's scopes. This is typically used when some data should always be accessible, but specific data requires additional permissions (e.g., private files, admin-only records, sensitive fields).

Unlike general scope validation (require_scope), which denies access with a 403 Forbidden error if the required scope is missing, this method still allows access but limits the data returned.

In this example, all users can retrieve a list of files, but only those with the files.read_private scope can see private files.

@app.get("/files")
async def list_files(token_scopes: list = Depends(get_token_scopes)):
    files = [{"id": 1, "name": "Private Doc", "shared": False}]
    
    # If the token lacks 'files.read_private', exclude private files
    if "files.read_private" not in token_scopes:
        files = [f for f in files if f["shared"]]
 
    return files

This ensures that users or applications with only files.read_public cannot access private data.

2. Use Role-Based Access Control (RBAC) with OAuth Scopes

Descope enables mapping RBAC roles to OAuth scopes, ensuring that API permissions align with organizational policies. This approach allows applications to enforce fine-grained access control while maintaining role-based governance.

Multiple roles can be mapped to a singular scope with Inbound Apps, therefore it's advantageous to use both scopes and roles for comprehensive access control.

Why Use Both Scopes and Roles?

While scopes and roles both control access, they serve different purposes, and using them together provides a more secure and scalable authorization model.

  1. Scopes define action-based access - OAuth scopes specify what an application or user can do within an API (e.g., contacts.read, contacts.write). They are best suited for enforcing API-level access control, particularly for third-party applications that request permissions dynamically.

  2. Roles define user-based access - Roles represent who the user is within an organization (e.g., editor, admin, manager). They provide a structured way to manage permissions internally, ensuring that only authorized users can access certain features or data.

  3. Using both ensures security and flexibility - Scopes enforce OAuth-based permissions, while roles help maintain business logic and organizational policies. Relying solely on scopes makes it difficult to manage internal user permissions, while using only roles makes it harder to enforce fine-grained API access, especially for external applications.

Example: Mapping Roles to Scopes in API Requests

When a user authenticates, their access token includes both scopes and roles. For example, a user with the editor role may receive the following token payload:

{
  "sub": "user123",
  "scope": "contacts.read contacts.write",
  "roles": ["editor"]
}

Enforcing Access Control with Scopes and Roles

The necessary steps therefore, that you will need to take to properly protect your APIs with OAuth scopes and roles are the following:

  1. Validate the OAuth scope - Ensure that the token includes the necessary scope for the requested API action.
  2. Check the user's role - Confirm that the user has a role that grants access to the resource or functionality.
  3. Apply business logic - Use the combination of scopes and roles to enforce least privilege access while maintaining organizational security policies.

Conclusion

Designing APIs with OAuth ensures secure, scalable, and fine-grained access control for applications, users, and third-party integrations. By leveraging inbound apps with Descope, developers can implement robust authentication and authorization mechanisms that enforce scopes, roles, and permissions dynamically.

By following these best practices, your API will be well-equipped to handle user authentication, enforce secure access, and integrate seamlessly with external services while maintaining compliance with the OAuth industry standards.

Was this helpful?