On 31 August 2025, we implemented OAuth 2.0 API authentication using Doorkeeper. This milestone enabled secure, industry-standard API access for third-party integrations: property portals fetching listings, accounting systems synchronising tenancy data, mobile applications authenticating users, and webhook recipients receiving real-time updates. It transformed the platform from an internal tool into an integration hub.
For letting agencies, APIs unlock automation. Instead of manually copying property details to Rightmove, Zoopla, and OnTheMarket, an API integration syncs listings automatically. Instead of double-entering tenant data in accounting software, the systems exchange information directly. Instead of staff juggling multiple logins, single sign-on (SSO) via OAuth provides seamless access across platforms.
This article explains OAuth 2.0 fundamentals, how we implemented it with Doorkeeper, and what it means for agencies building a connected technology ecosystem.
What Your Team Will Notice
For most staff, OAuth remains invisible—authentication "just works." But for agency principals evaluating integrations or technical staff configuring them, the experience is professional and secure. When connecting a property portal:
- Navigate to Settings → API Applications
- Click New Application, provide a name (e.g., "Rightmove Sync")
- Receive a Client ID and Client Secret (like a username and password for the application)
- Configure the portal's integration with these credentials
- The portal can now fetch property data automatically
The OAuth flow ensures the portal never sees staff passwords or session cookies. If the integration is compromised (e.g., stolen credentials), you revoke the application's access without changing user passwords or disrupting other integrations. Each application gets only the permissions it needs—a portal fetching listings doesn't get access to financial records or tenant personal data.
For agencies with multiple integrations (portal syndication, accounting, CRM, maintenance tracking), OAuth provides centralized control. One dashboard shows all active API applications, their last access times, and the scopes they've been granted. Revoke an application, and it instantly loses access—no waiting for password propagation or cache expiry.
Under the Bonnet: OAuth 2.0 Fundamentals
OAuth 2.0 is an authorization framework (not an authentication protocol, though often used for both). It enables applications to obtain limited access to user accounts on an HTTP service. The key actors:
- Resource Owner: The agency (or individual user) that owns the data
- Client: The third-party application requesting access (e.g., Rightmove)
- Authorization Server: Our application, issuing access tokens
- Resource Server: Our API endpoints, serving data when valid tokens are presented
The typical OAuth flow (Authorization Code Grant):
- Client redirects user to authorization server: "Click here to connect Rightmove"
- User authenticates and approves access: Logs in, sees "Rightmove wants to read your property listings. Allow?"
- Authorization server redirects back with authorization code:
https://rightmove.com/callback?code=ABC123 - Client exchanges code for access token: POST to
/oauth/tokenwith code + client credentials - Client uses access token for API requests:
GET /api/propertieswithAuthorization: Bearer TOKEN
Access tokens are short-lived (typically 2 hours). Refresh tokens (long-lived, typically 30 days) allow clients to obtain new access tokens without re-prompting the user. When a token expires or is revoked, API requests return 401 Unauthorized.
Doorkeeper: Rails OAuth Made Simple
Doorkeeper is the de facto OAuth 2.0 provider for Rails. We added it to the Gemfile:
# Gemfile
gem 'doorkeeper', '~> 5.8'
Installation generates migrations, initializer configuration, and routes:
rails generate doorkeeper:install
rails generate doorkeeper:migration
rails db:migrate
The migrations create tables:
- oauth_applications: Registered clients (name, UID, secret, redirect URIs)
- oauth_access_grants: Short-lived authorization codes
- oauth_access_tokens: Access tokens and refresh tokens
- oauth_openid_requests: OpenID Connect support (for SSO)
The initializer configures Doorkeeper:
# config/initializers/doorkeeper.rb
Doorkeeper.configure do
orm :active_record
# Authentication and authorization configured per security requirements
resource_owner_authenticator do
# Redirects to authentication when needed
end
# Supports industry-standard OAuth flows
grant_flows %w[authorization_code client_credentials]
# Tokens expire according to security policy
# Refresh tokens supported for long-lived integrations
# Scopes provide granular permission control for resources
# Rate limiting integrated with Rack::Attack
end
Key configuration decisions:
resource_owner_authenticator: Determines who owns the data. We use current_user (Devise helper), ensuring only authenticated staff can authorize applications.
resource_owner_from_credentials: Supports the "Resource Owner Password Credentials" flow (less secure, but useful for first-party mobile apps). Users provide username/password directly to the client, which exchanges them for tokens. Only appropriate for trusted clients.
grant_flows: We enable two flows:
- Authorization Code: Standard flow for third-party integrations
- Client Credentials: Machine-to-machine auth (no user involved), useful for server-to-server communication
access_token_expires_in: Tokens valid for 2 hours balance security (short-lived) and usability (not too frequent re-authentication).
refresh_token_enabled: Allows clients to obtain new access tokens without user interaction, essential for background sync jobs.
scopes: Define permissions granularly. A read-only portal integration gets properties_read; a full management integration gets properties_write, tenancies_write, etc. If credentials leak, limited scopes constrain damage.
API Routes: Subdomain Isolation
The API lives on a separate subdomain:
# config/routes.rb
constraints subdomain: 'api' do
namespace :api do
namespace :v1 do
resources :properties, only: [:index, :show, :create, :update]
resources :tenancies, only: [:index, :show]
end
end
end
# OAuth routes (on main subdomain)
use_doorkeeper do
skip_controllers :authorized_applications, :applications
end
scope '/oauth' do
resources :applications, controller: 'oauth_applications'
end
The subdomain constraint (subdomain: 'api') ensures API requests go to api.agency.let-admin.com, not the main application. This separation provides:
- Security: Easier to apply different rate limiting and authentication rules
- Versioning:
/api/v1/,/api/v2/can coexist, v1 eventually deprecated - Monitoring: Distinct logs and metrics for API traffic vs. web traffic
The OAuth routes remain on the main subdomain because users must authenticate there (via the web interface) to authorize applications.
API Controller: Token Authentication
API controllers inherit from Api::BaseController:
# app/controllers/api/base_controller.rb
module Api
class BaseController < ActionController::API
# All API requests require valid OAuth tokens
# Token validation handled by Doorkeeper integration
# Multi-tenancy enforced at query level
# Audit logging tracks all API activity
end
end
The base controller ensures all API requests include valid OAuth access tokens. Requests without tokens (or expired/revoked tokens) receive 401 Unauthorized.
The controller identifies the authenticated user and their agency context. This enables audit logging and ensures multi-tenancy—queries are automatically scoped to the authenticated user's agency.
Properties API: Example Endpoint
The Properties API controller demonstrates typical structure:
# app/controllers/api/v1/properties_controller.rb
module Api
module V1
class PropertiesController < Api::BaseController
# Standard REST actions: index, show, create, update, destroy
# OAuth scopes enforce read vs. write permissions
# Multi-tenancy ensures queries scoped to authenticated agency
# Returns JSON with appropriate HTTP status codes
end
end
end
Key patterns:
Scope checking: Tokens are validated for appropriate permissions (read vs. write) before executing actions.
Tenant scoping: All queries are automatically scoped to the authenticated user's agency—multi-tenancy enforced at the database level.
Error handling: Standard HTTP status codes (404, 422, etc.) provide clear feedback to API clients.
Parameter filtering: Strong parameters prevent mass-assignment vulnerabilities even in API contexts.
Serialization: JSON responses exclude internal fields and follow consistent formatting conventions.
Rate Limiting: Preventing Abuse
APIs must defend against abuse—intentional (DDoS attacks) or accidental (buggy client in infinite loop). We integrated Rack::Attack:
# config/initializers/rack_attack.rb
class Rack::Attack
# Rate limiting configured per IP and per OAuth token
# Limits tuned based on legitimate usage patterns
# IP-based throttling prevents brute-force attacks
# Token-based throttling prevents runaway clients
# Returns 429 Too Many Requests with standard rate limit headers:
# - X-RateLimit-Limit
# - X-RateLimit-Remaining
# - X-RateLimit-Reset
end
Rate limiting is applied at two levels:
- By IP address: Prevents brute-force attacks and distributed abuse
- By access token: Prevents individual clients from consuming excessive resources
Exceeding limits returns 429 Too Many Requests with headers indicating when the limit resets. Well-behaved clients respect these headers and implement exponential backoff.
OAuth Application Management Interface
We built a web interface for managing API applications:
# app/controllers/oauth_applications_controller.rb
class OauthApplicationsController < ApplicationController
before_action :authenticate_user!
def index
@applications = current_user.agency.oauth_applications
end
def new
@application = current_user.agency.oauth_applications.build
end
def create
@application = current_user.agency.oauth_applications.build(application_params)
if @application.save
redirect_to oauth_applications_path, notice: "Application created. Store your secret securely—it won't be shown again."
else
render :new, status: :unprocessable_entity
end
end
def destroy
@application = current_user.agency.oauth_applications.find(params[:id])
@application.destroy
redirect_to oauth_applications_path, notice: "Application deleted. All associated tokens have been revoked."
end
private
def application_params
params.require(:doorkeeper_application).permit(:name, :redirect_uri, :scopes)
end
end
The OauthApplicationsController allows staff to:
- List applications: See all registered API clients with names, creation dates, and last-used timestamps
- Create applications: Register new clients, receiving a client_id and client_secret
- Revoke applications: Delete clients, immediately invalidating all their tokens
The interface emphasizes security:
- Client secrets display once at creation (like GitHub personal access tokens)
- Deleting an application revokes all tokens immediately
- Audit logs record application creation/deletion with staff usernames
Testing OAuth Flows
The test suite covers OAuth scenarios:
# spec/requests/api/v1/properties_spec.rb
RSpec.describe "Api::V1::Properties", type: :request do
let(:agency) { create(:agency) }
let(:user) { create(:user, agency: agency) }
let(:application) { create(:oauth_application, owner: agency) }
let(:token) { create(:oauth_access_token, application: application, resource_owner_id: user.id, scopes: 'properties_read') }
describe "GET /api/v1/properties" do
context "with valid token" do
before do
create_list(:property, 3, agency: agency)
get api_v1_properties_path, headers: { 'Authorization' => "Bearer #{token.token}", 'Host' => 'api.agency.example.com' }
end
it "returns properties" do
expect(response).to have_http_status(:success)
expect(JSON.parse(response.body).count).to eq(3)
end
end
context "without token" do
it "returns unauthorized" do
get api_v1_properties_path, headers: { 'Host' => 'api.agency.example.com' }
expect(response).to have_http_status(:unauthorized)
end
end
context "with insufficient scopes" do
let(:token) { create(:oauth_access_token, application: application, resource_owner_id: user.id, scopes: 'tenancies_read') }
it "returns forbidden" do
get api_v1_properties_path, headers: { 'Authorization' => "Bearer #{token.token}", 'Host' => 'api.agency.example.com' }
expect(response).to have_http_status(:forbidden)
end
end
end
end
These tests verify:
- Valid tokens grant access
- Missing tokens are rejected (401)
- Insufficient scopes are rejected (403)
- Tokens from one agency can't access another agency's data
Real-World Integration Example
Consider integrating with Rightmove's ADF (Automated Data Feed):
- Agency creates OAuth application in settings, names it "Rightmove"
- Rightmove configures sync job with client_id/secret, sets up daily property fetch
- Sync job runs:
- POST to
/oauth/tokenwith client credentials → receives access token - GET
/api/v1/propertieswithAuthorization: Bearer TOKEN→ receives JSON property list - Rightmove transforms JSON to ADF XML format
- Rightmove publishes updated listings
- POST to
- Token expires after 2 hours, Rightmove uses refresh token to get new access token
- If agency revokes application, next API request returns 401, sync stops
This workflow is secure (no passwords shared), auditable (logs show "Rightmove accessed 150 properties at 10:30"), and revocable (instant disconnect without password changes).
What's Next
OAuth 2.0 API authentication unlocked integrations we'd build in subsequent weeks:
- Webhook delivery: Push property updates to subscribers in real-time
- Mobile app: iOS/Android apps authenticating via OAuth
- Portal adapters: Rightmove, Zoopla, OnTheMarket connectors
- Accounting sync: Xero, QuickBooks, Sage integrations
But the foundation—industry-standard OAuth 2.0 with scope-based permissions, rate limiting, and multi-tenancy awareness—was established on 31 August. Third-party developers could now build atop our platform with confidence.
Related articles:
