# How API Authentication with Doorkeeper OAuth Works in Maybe Finance

> Explore how Maybe Finance secures its API with Doorkeeper OAuth. Learn how access tokens validate requests and establish user context in Api::V1::BaseController.

- Repository: [Maybe/maybe](https://github.com/maybe-finance/maybe)
- Tags: deep-dive
- Published: 2026-03-07

---

**The Maybe Finance API secures every endpoint using OAuth 2.0 access tokens issued by the Doorkeeper gem, validating requests in `Api::V1::BaseController` to establish the current user context.**

The maybe-finance/maybe repository implements a stateless API authentication with Doorkeeper OAuth to protect financial data endpoints while maintaining compatibility with the traditional session-based web interface. This Rails application uses Doorkeeper to manage token issuance and validation, ensuring that every API request carries a valid Bearer token with appropriate scopes.

## OAuth Token Issuance and Resource Owner Resolution

The authentication flow begins when clients request an access token from the Doorkeeper token endpoint.

### Configuring the Resource Owner Authenticator

In **[`config/initializers/doorkeeper.rb`](https://github.com/maybe-finance/maybe/blob/main/config/initializers/doorkeeper.rb)**, the `resource_owner_authenticator` block defines how Doorkeeper maps an incoming request to a User record. The implementation extracts the `session_token` from cookies and looks up the corresponding user:

```ruby
resource_owner_authenticator do
  session_token = cookies.encrypted[:session_token]
  User.find_by(id: session_token&.dig("user_id"))
end

```

This configuration allows Doorkeeper to issue tokens bound to specific users while leveraging the existing session infrastructure.

## Token Validation in API Controllers

All API controllers inherit from **`Api::V1::BaseController`**, which implements custom token validation logic rather than using Doorkeeper's default `before_action` helpers.

### Manual Token Lookup in BaseController

The controller extracts the Bearer token from the `Authorization` header and performs a manual database lookup:

```ruby
token_string = request.authorization&.split(" ")&.last
access_token = Doorkeeper::AccessToken.by_token(token_string)

```

This approach provides fine-grained control over error handling and response formatting.

### Scope Verification and Expiration Checks

After retrieving the token, the controller validates three conditions:

1. **Existence**: The token must exist in the database
2. **Expiration**: The token must not be expired (`token.expired?` check)
3. **Scope**: The token must include at least the `read` scope, or `read_write` for mutation endpoints

If any check fails, the controller returns a JSON error response with HTTP 401 status.

## Setting Up Current User Context

When validation succeeds, the controller establishes the application context for the request:

```ruby
@_doorkeeper_token = access_token
Current.user = User.find(access_token.resource_owner_id)
Current.session = Session.new(user: Current.user)

```

This creates a temporary `Session` object and sets `Current.user`, allowing the rest of the application to rely on these globals regardless of whether the request came from the web UI or the API.

## Error Handling for Invalid Tokens

The controller overrides `doorkeeper_unauthorized_render_options` to ensure API clients receive JSON responses rather than HTML redirects:

```ruby
def doorkeeper_unauthorized_render_options(error: nil)
  {
    json: { error: "unauthorized", message: error.description },
    status: :unauthorized
  }
end

```

This method returns a 401 status with a descriptive error message when token validation fails.

## Route Configuration

Doorkeeper routes are mounted in **[`config/routes.rb`](https://github.com/maybe-finance/maybe/blob/main/config/routes.rb)** using the `use_doorkeeper` method:

```ruby
use_doorkeeper do
  skip_controllers :authorizations, :applications, :authorized_applications
end

namespace :api do
  namespace :v1 do
    # API endpoints...

  end
end

```

This configuration exposes the standard OAuth endpoints (`/oauth/token`, `/oauth/revoke`) while skipping the authorization flow controllers that are unnecessary for a first-party API.

## Practical Examples

### Obtaining an Access Token

Clients authenticate using the password grant flow to receive an access token:

```bash
curl -X POST https://api.maybe.finance/oauth/token \
  -d "grant_type=password" \
  -d "username=user@example.com" \
  -d "password=secret123" \
  -d "client_id=YOUR_CLIENT_ID" \
  -d "client_secret=YOUR_CLIENT_SECRET" \
  -d "scope=read_write"

```

### Calling a Protected Endpoint

Include the access token in the Authorization header for all API requests:

```bash
curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
     -H "Content-Type: application/json" \
     https://api.maybe.finance/api/v1/accounts

```

### Refreshing an Access Token

When the access token expires, use the refresh token to obtain a new one:

```bash
curl -X POST https://api.maybe.finance/oauth/token \
  -d "grant_type=refresh_token" \
  -d "refresh_token=YOUR_REFRESH_TOKEN" \
  -d "client_id=YOUR_CLIENT_ID" \
  -d "client_secret=YOUR_CLIENT_SECRET"

```

## Key Implementation Files

| File | Purpose |
|------|---------|
| **[`config/initializers/doorkeeper.rb`](https://github.com/maybe-finance/maybe/blob/main/config/initializers/doorkeeper.rb)** | Configures Doorkeeper, defines the `resource_owner_authenticator` block that maps session cookies to User records, and sets token expiration and scopes |
| **[`config/routes.rb`](https://github.com/maybe-finance/maybe/blob/main/config/routes.rb)** | Mounts Doorkeeper routes via `use_doorkeeper` and defines the API namespace |
| **[`app/controllers/api/v1/base_controller.rb`](https://github.com/maybe-finance/maybe/blob/main/app/controllers/api/v1/base_controller.rb)** | Validates Bearer tokens manually, checks scopes and expiration, sets `Current.user` and `Current.session`, and returns JSON 401 errors |
| **[`app/models/user.rb`](https://github.com/maybe-finance/maybe/blob/main/app/models/user.rb)** | The resource owner model associated with OAuth tokens via `resource_owner_id` |
| **[`app/models/api_key.rb`](https://github.com/maybe-finance/maybe/blob/main/app/models/api_key.rb)** | Provides alternative API key authentication when OAuth tokens are not used |

## Summary

- **Doorkeeper Configuration**: The `resource_owner_authenticator` in [`config/initializers/doorkeeper.rb`](https://github.com/maybe-finance/maybe/blob/main/config/initializers/doorkeeper.rb) links OAuth tokens to users via session cookies.
- **Manual Token Validation**: `Api::V1::BaseController` performs explicit Bearer token extraction and validation using `Doorkeeper::AccessToken.by_token`.
- **Scope Enforcement**: The API checks for `read` or `read_write` scopes before allowing access to endpoints.
- **Context Initialization**: Valid tokens trigger the creation of `Current.user` and `Current.session` objects for downstream application logic.
- **JSON Error Responses**: Invalid or missing tokens return HTTP 401 with JSON error bodies rather than HTML redirects.

## Frequently Asked Questions

### How does Doorkeeper integrate with the existing User model?

Doorkeeper uses the `resource_owner_authenticator` block defined in [`config/initializers/doorkeeper.rb`](https://github.com/maybe-finance/maybe/blob/main/config/initializers/doorkeeper.rb) to resolve the user associated with a token request. This block extracts the encrypted `session_token` cookie and looks up the corresponding `User` record, allowing Doorkeeper to issue access tokens bound to specific users while leveraging the existing session infrastructure.

### What OAuth scopes are available for API tokens?

The Maybe Finance API supports two primary scopes: `read` and `read_write`. The `read` scope grants access to GET endpoints for retrieving financial data, while `read_write` is required for POST, PUT, PATCH, and DELETE operations that modify data. The `Api::V1::BaseController` explicitly checks these scopes during token validation.

### How does the API handle expired or invalid tokens?

When a request contains an expired, revoked, or malformed token, the `Api::V1::BaseController` returns an HTTP 401 Unauthorized response with a JSON error body. This behavior is implemented through the overridden `doorkeeper_unauthorized_render_options` method, which ensures API clients receive machine-readable error messages rather than HTML redirects.

### Can API keys be used instead of OAuth tokens?

Yes, the application supports an alternative authentication method via API keys defined in [`app/models/api_key.rb`](https://github.com/maybe-finance/maybe/blob/main/app/models/api_key.rb). When a request does not include a valid OAuth Bearer token, the system can fall back to validating API keys, providing flexibility for service-to-service integrations or legacy client support alongside the primary Doorkeeper OAuth implementation.