Overview
OAuth 2.0 provides an authorization framework that applications use to obtain limited access to user accounts on HTTP services. The protocol defines methods for third-party applications to access server resources on behalf of a resource owner without exposing credentials. OAuth replaced OAuth 1.0, addressing security vulnerabilities and implementation complexity.
OpenID Connect (OIDC) extends OAuth 2.0 to add an identity layer. While OAuth handles authorization (what a user can do), OpenID Connect handles authentication (who the user is). OpenID Connect returns standardized identity information as JSON Web Tokens (JWTs), allowing clients to verify user identity and obtain basic profile information.
The protocols separate concerns across distinct roles: resource owners (users), clients (applications), authorization servers (issue tokens), and resource servers (host protected resources). This separation enables flexible integration patterns while maintaining security boundaries.
OAuth and OpenID Connect dominate modern web authentication. Social login buttons, API access tokens, mobile app authentication, and single sign-on systems rely on these protocols. The specification's flexibility accommodates web applications, mobile apps, server-to-server communication, and IoT devices.
# Basic OAuth 2.0 authorization code flow
require 'oauth2'
client = OAuth2::Client.new(
'client_id',
'client_secret',
site: 'https://provider.com',
authorize_url: '/oauth/authorize',
token_url: '/oauth/token'
)
# Redirect user to authorization URL
auth_url = client.auth_code.authorize_url(
redirect_uri: 'https://app.com/callback',
scope: 'read_profile'
)
# After user authorizes, exchange code for token
token = client.auth_code.get_token(
params[:code],
redirect_uri: 'https://app.com/callback'
)
# => #<OAuth2::AccessToken>
Key Principles
OAuth 2.0 operates on token-based authorization. Instead of sharing passwords, resource owners grant limited access through tokens. Access tokens carry authorization data with defined scopes and expiration times. Refresh tokens enable obtaining new access tokens without repeated user interaction.
The authorization server issues tokens after verifying the resource owner's consent. Tokens contain no credential information but reference authorization grants stored server-side. Resource servers validate tokens before granting access, checking both authenticity and scope permissions.
Scopes define permission boundaries. A scope like read:profile grants read access to profile data, while write:posts allows creating posts. Applications request specific scopes during authorization. Users see requested permissions and approve or deny access. The authorization server issues tokens limited to approved scopes.
Grant types determine how clients obtain tokens. The authorization code grant suits web applications with server-side components. The implicit grant served browser-based applications but faces security concerns. The client credentials grant enables server-to-server communication. The resource owner password credentials grant applies when other flows cannot work but requires high trust levels.
OpenID Connect introduces ID tokens alongside access tokens. ID tokens contain claims about the authenticated user encoded as JWTs. Standard claims include sub (subject identifier), iss (issuer), aud (audience), and exp (expiration). Custom claims carry additional user attributes.
# OpenID Connect adds ID token to OAuth flow
require 'openid_connect'
client = OpenIDConnect::Client.new(
identifier: 'client_id',
secret: 'client_secret',
authorization_endpoint: 'https://provider.com/authorize',
token_endpoint: 'https://provider.com/token',
userinfo_endpoint: 'https://provider.com/userinfo'
)
# Authorization includes openid scope
authorization_uri = client.authorization_uri(
scope: [:openid, :profile, :email],
state: SecureRandom.hex(16),
nonce: SecureRandom.hex(16)
)
# Token response includes id_token
client.authorization_code = params[:code]
access_token = client.access_token!
# => Returns both access_token and id_token
# Decode and verify ID token
id_token = OpenIDConnect::ResponseObject::IdToken.decode(
access_token.id_token,
client.secret
)
# => #<OpenIDConnect::ResponseObject::IdToken>
Token validation prevents security vulnerabilities. ID tokens require signature verification using the provider's public key. Clients check the iss claim matches the expected issuer and the aud claim includes the client's identifier. The exp claim must exceed current time. The nonce claim must match the value sent in the authorization request.
State parameters protect against CSRF attacks. Clients generate random state values before redirecting to authorization endpoints. After authorization, clients verify the returned state matches the original value. Attackers cannot forge valid state parameters without intercepting the initial request.
Discovery documents simplify provider integration. OpenID Connect providers publish configuration at /.well-known/openid-configuration. The document lists endpoint URLs, supported grant types, signing algorithms, and available claims. Clients fetch this document to configure themselves dynamically.
# OpenID Connect discovery
discovery = OpenIDConnect::Discovery::Provider::Config.discover!(
'https://accounts.google.com'
)
discovery.authorization_endpoint
# => "https://accounts.google.com/o/oauth2/v2/auth"
discovery.token_endpoint
# => "https://accounts.google.com/oauth2/v4/token"
discovery.jwks_uri
# => "https://www.googleapis.com/oauth2/v3/certs"
Implementation Approaches
Authorization Code Flow provides the strongest security for web applications. The client redirects users to the authorization server, which authenticates the user and displays a consent screen. After approval, the authorization server redirects back with an authorization code. The client exchanges this code for tokens via a back-channel request. This approach prevents token exposure in browser history or logs.
The flow requires server-side code to keep client secrets confidential. Single-page applications cannot safely use this flow without a backend proxy. The authorization code itself provides minimal risk if intercepted since it requires the client secret for token exchange.
# Authorization code flow implementation
class OAuthController < ApplicationController
def authorize
client = oauth_client
session[:oauth_state] = SecureRandom.hex(16)
redirect_to client.auth_code.authorize_url(
redirect_uri: callback_url,
scope: 'read write',
state: session[:oauth_state]
), allow_other_host: true
end
def callback
# Verify state parameter
if params[:state] != session[:oauth_state]
return render json: { error: 'Invalid state' }, status: 400
end
# Exchange code for token
client = oauth_client
token = client.auth_code.get_token(
params[:code],
redirect_uri: callback_url
)
session[:access_token] = token.token
session[:refresh_token] = token.refresh_token
redirect_to dashboard_path
end
private
def oauth_client
OAuth2::Client.new(
ENV['OAUTH_CLIENT_ID'],
ENV['OAUTH_CLIENT_SECRET'],
site: ENV['OAUTH_PROVIDER_URL']
)
end
end
Authorization Code Flow with PKCE adapts the authorization code flow for public clients. PKCE (Proof Key for Code Exchange) eliminates the client secret requirement. The client generates a random code verifier, then creates a code challenge by hashing the verifier. The authorization request includes the code challenge. The token request includes the original code verifier. The server verifies the verifier produces the challenge, proving the same client made both requests.
Mobile applications and single-page applications use PKCE to prevent authorization code interception attacks. The approach maintains authorization code flow security without requiring client secrets in public clients.
# PKCE flow for public clients
require 'digest'
require 'base64'
# Generate code verifier
code_verifier = SecureRandom.urlsafe_base64(32)
session[:code_verifier] = code_verifier
# Create code challenge
code_challenge = Base64.urlsafe_encode64(
Digest::SHA256.digest(code_verifier),
padding: false
)
# Authorization request includes challenge
auth_url = client.auth_code.authorize_url(
redirect_uri: callback_url,
scope: 'read',
code_challenge: code_challenge,
code_challenge_method: 'S256'
)
# Token request includes verifier
token = client.auth_code.get_token(
params[:code],
redirect_uri: callback_url,
code_verifier: session[:code_verifier]
)
Client Credentials Flow enables server-to-server authentication without user involvement. The client authenticates directly with the authorization server using its credentials. The server issues an access token for the client's own resources or resources it manages. This flow suits background jobs, API integrations, and service accounts.
The approach assumes the client securely stores credentials. Tokens represent the client's identity rather than a user's identity. Scopes limit the client's capabilities but no user consent occurs.
Implicit Flow allowed browser-based applications to receive tokens directly without an authorization code exchange. The authorization server returns access tokens in the redirect URI fragment. Security concerns around token exposure in browser history and lack of client authentication led to PKCE replacing this flow for most use cases. The OAuth 2.0 Security Best Current Practice recommends against using implicit flow.
Ruby Implementation
Ruby applications integrate OAuth and OpenID Connect through several gems. The oauth2 gem provides low-level OAuth 2.0 protocol support. The omniauth gem offers a standardized interface for multiple authentication providers. The openid_connect gem implements OpenID Connect specifications.
OAuth2 Gem handles token requests, token refresh, and authenticated API calls. The gem defines client objects representing OAuth providers and token objects representing authorized sessions.
# Complete OAuth 2.0 implementation with error handling
require 'oauth2'
class OAuthService
TOKEN_OPTIONS = {
mode: :body,
header_format: 'Bearer %s'
}.freeze
def initialize(provider_config)
@client = OAuth2::Client.new(
provider_config[:client_id],
provider_config[:client_secret],
site: provider_config[:site],
token_url: provider_config[:token_url],
authorize_url: provider_config[:authorize_url],
token_method: :post
)
end
def authorization_url(redirect_uri, scope, state)
@client.auth_code.authorize_url(
redirect_uri: redirect_uri,
scope: scope,
state: state
)
end
def exchange_code(code, redirect_uri)
@client.auth_code.get_token(
code,
redirect_uri: redirect_uri
)
rescue OAuth2::Error => e
handle_oauth_error(e)
end
def refresh_token(refresh_token_string)
token = OAuth2::AccessToken.from_hash(
@client,
refresh_token: refresh_token_string
)
token.refresh!
rescue OAuth2::Error => e
handle_oauth_error(e)
end
def make_api_call(access_token_string, endpoint)
token = OAuth2::AccessToken.new(@client, access_token_string)
response = token.get(endpoint)
JSON.parse(response.body)
rescue OAuth2::Error => e
handle_oauth_error(e)
end
private
def handle_oauth_error(error)
case error.code
when 'invalid_grant'
raise AuthorizationExpiredError, 'Token expired or invalid'
when 'invalid_client'
raise AuthenticationError, 'Invalid client credentials'
when 'access_denied'
raise AccessDeniedError, 'User denied access'
else
raise OAuthError, "OAuth error: #{error.message}"
end
end
end
OmniAuth abstracts provider differences behind a common interface. Rails applications mount OmniAuth as Rack middleware. After successful authentication, OmniAuth populates request.env['omniauth.auth'] with standardized user data.
# OmniAuth configuration for multiple providers
# config/initializers/omniauth.rb
Rails.application.config.middleware.use OmniAuth::Builder do
provider :google_oauth2,
ENV['GOOGLE_CLIENT_ID'],
ENV['GOOGLE_CLIENT_SECRET'],
scope: 'email,profile',
prompt: 'select_account'
provider :github,
ENV['GITHUB_CLIENT_ID'],
ENV['GITHUB_CLIENT_SECRET'],
scope: 'user:email'
provider :oauth2,
ENV['CUSTOM_CLIENT_ID'],
ENV['CUSTOM_CLIENT_SECRET'],
client_options: {
site: ENV['CUSTOM_PROVIDER_URL'],
authorize_url: '/oauth/authorize',
token_url: '/oauth/token'
}
end
# Controller handling OmniAuth callback
class SessionsController < ApplicationController
def create
auth_hash = request.env['omniauth.auth']
user = User.find_or_create_by(
provider: auth_hash['provider'],
uid: auth_hash['uid']
) do |u|
u.email = auth_hash.dig('info', 'email')
u.name = auth_hash.dig('info', 'name')
u.oauth_token = auth_hash.dig('credentials', 'token')
u.oauth_refresh_token = auth_hash.dig('credentials', 'refresh_token')
u.oauth_expires_at = Time.at(auth_hash.dig('credentials', 'expires_at'))
end
session[:user_id] = user.id
redirect_to root_path
end
def failure
flash[:error] = params[:message]
redirect_to login_path
end
end
OpenID Connect Gem implements ID token validation, userinfo endpoint requests, and discovery protocol support. The gem handles JWT signature verification and claim validation automatically.
# OpenID Connect implementation with ID token validation
require 'openid_connect'
require 'json/jwt'
class OpenIDService
def initialize
@discovery = OpenIDConnect::Discovery::Provider::Config.discover!(
ENV['OIDC_ISSUER']
)
@client = OpenIDConnect::Client.new(
identifier: ENV['OIDC_CLIENT_ID'],
secret: ENV['OIDC_CLIENT_SECRET'],
authorization_endpoint: @discovery.authorization_endpoint,
token_endpoint: @discovery.token_endpoint,
userinfo_endpoint: @discovery.userinfo_endpoint
)
end
def authorization_url(redirect_uri, state, nonce)
@client.authorization_uri(
scope: [:openid, :profile, :email],
state: state,
nonce: nonce,
redirect_uri: redirect_uri
)
end
def handle_callback(code, redirect_uri, expected_nonce)
@client.authorization_code = code
@client.redirect_uri = redirect_uri
token_response = @client.access_token!
# Decode ID token
id_token = decode_id_token(
token_response.id_token,
expected_nonce
)
# Optionally fetch additional user info
userinfo = token_response.userinfo!
{
id_token: id_token,
userinfo: userinfo,
access_token: token_response.access_token,
refresh_token: token_response.refresh_token
}
end
def decode_id_token(token_string, expected_nonce)
# Fetch public keys from JWKS endpoint
jwks = JSON::JWK::Set.new(
JSON.parse(Net::HTTP.get(URI(@discovery.jwks_uri)))
)
# Decode and verify token
id_token = JSON::JWT.decode(token_string, jwks)
# Validate claims
validate_id_token_claims(id_token, expected_nonce)
id_token
end
private
def validate_id_token_claims(id_token, expected_nonce)
# Verify issuer
unless id_token['iss'] == @discovery.issuer
raise 'Invalid issuer'
end
# Verify audience
unless id_token['aud'] == ENV['OIDC_CLIENT_ID']
raise 'Invalid audience'
end
# Verify expiration
if Time.at(id_token['exp']) < Time.now
raise 'Token expired'
end
# Verify nonce
unless id_token['nonce'] == expected_nonce
raise 'Invalid nonce'
end
end
end
Token storage requires careful consideration. Session storage works for short-lived access tokens. Database storage suits applications needing tokens across sessions. Encrypted database columns protect tokens at rest. Never log tokens or include them in error reports.
# Secure token storage model
class OAuthCredential < ApplicationRecord
belongs_to :user
encrypts :access_token
encrypts :refresh_token
validates :provider, presence: true
validates :access_token, presence: true
def expired?
expires_at && expires_at < Time.current
end
def refresh!
service = OAuthService.new(provider_configuration)
new_token = service.refresh_token(refresh_token)
update!(
access_token: new_token.token,
refresh_token: new_token.refresh_token || refresh_token,
expires_at: Time.at(new_token.expires_at)
)
end
def provider_configuration
# Load from config based on provider
Rails.application.credentials.dig(:oauth, provider.to_sym)
end
end
Security Implications
Token Leakage poses the primary OAuth security risk. Access tokens function like temporary passwords. Attackers gaining token access can impersonate users within the token's scope. Applications must protect tokens with the same rigor as passwords.
Transport security requires HTTPS for all OAuth communications. Authorization redirects, token exchanges, and API requests must use TLS. Mixed content warnings indicate security vulnerabilities. Certificate validation must remain enabled.
Redirect URI Validation prevents authorization code interception. Authorization servers must validate redirect URIs match pre-registered values exactly. Partial matches or substring validation create vulnerabilities. Open redirectors in OAuth clients enable attackers to receive authorization codes.
# Secure redirect URI validation
class OAuthProvider
ALLOWED_REDIRECT_URIS = {
'client_123' => [
'https://app.example.com/callback',
'https://app.example.com/oauth/callback'
],
'client_456' => [
'https://other.example.com/auth/complete'
]
}.freeze
def validate_redirect_uri(client_id, redirect_uri)
allowed_uris = ALLOWED_REDIRECT_URIS[client_id]
return false unless allowed_uris
# Exact match required, no wildcards or patterns
allowed_uris.include?(redirect_uri)
end
def authorize(client_id, redirect_uri, scope, state)
unless validate_redirect_uri(client_id, redirect_uri)
render json: { error: 'invalid_redirect_uri' }, status: 400
return
end
# Continue authorization flow
end
end
State Parameter CSRF Protection defends against cross-site request forgery. Attackers trick victims into clicking authorization URLs without state validation. The attacker's account becomes linked to the victim's OAuth credentials. Applications must generate cryptographically random state values and verify them on callback.
PKCE mitigates authorization code interception attacks. Mobile applications and public clients face code interception risks. PKCE ensures only the client that started the flow can exchange the code for tokens. All public clients should implement PKCE.
Token Expiration limits damage from leaked tokens. Short-lived access tokens reduce the window of exposure. Refresh tokens enable obtaining new access tokens without user interaction. Applications should use 15-60 minute access token lifetimes. Refresh tokens may live days or months depending on security requirements.
# Token expiration checking middleware
class TokenExpirationMiddleware
def initialize(app)
@app = app
end
def call(env)
request = Rack::Request.new(env)
user = current_user(request)
if user && user.oauth_credential&.expired?
begin
user.oauth_credential.refresh!
rescue AuthorizationExpiredError
# Refresh token also expired, require re-authentication
return [401, {}, ['Token expired, re-authentication required']]
end
end
@app.call(env)
end
private
def current_user(request)
return nil unless request.session[:user_id]
User.find_by(id: request.session[:user_id])
end
end
Scope Minimization reduces risk exposure. Applications should request only required scopes. Users more readily approve minimal permission requests. Compromised tokens with limited scopes restrict attacker capabilities. Review scope requirements regularly.
Client Secret Protection applies to confidential clients. Secrets must never appear in client-side code, version control, or logs. Environment variables or secret management systems provide better storage. Rotate secrets after exposure or on a regular schedule.
ID Token Validation prevents token substitution attacks. Applications must verify signature, issuer, audience, and expiration. Nonce validation ensures the token was issued in response to the current authentication request. Skipping validation allows attackers to use tokens issued to different clients.
# Complete ID token validation
class IDTokenValidator
def initialize(discovery_config, client_id)
@discovery = discovery_config
@client_id = client_id
@jwks = fetch_jwks
end
def validate(token_string, expected_nonce)
# Decode token
id_token = decode_token(token_string)
# Perform all validations
validate_issuer(id_token)
validate_audience(id_token)
validate_expiration(id_token)
validate_issued_at(id_token)
validate_nonce(id_token, expected_nonce)
id_token
end
private
def decode_token(token_string)
JSON::JWT.decode(token_string, @jwks)
rescue JSON::JWT::Exception => e
raise ValidationError, "Token decode failed: #{e.message}"
end
def validate_issuer(token)
unless token['iss'] == @discovery.issuer
raise ValidationError,
"Invalid issuer: expected #{@discovery.issuer}, got #{token['iss']}"
end
end
def validate_audience(token)
audiences = Array(token['aud'])
unless audiences.include?(@client_id)
raise ValidationError, "Token not intended for this client"
end
end
def validate_expiration(token)
exp = Time.at(token['exp'])
if exp < Time.now
raise ValidationError, "Token expired at #{exp}"
end
end
def validate_issued_at(token)
# Token should not be issued in the future
iat = Time.at(token['iat'])
if iat > Time.now + 60 # Allow 60 second clock skew
raise ValidationError, "Token issued in the future"
end
end
def validate_nonce(token, expected_nonce)
unless token['nonce'] == expected_nonce
raise ValidationError, "Nonce mismatch"
end
end
def fetch_jwks
jwks_response = Net::HTTP.get(URI(@discovery.jwks_uri))
JSON::JWK::Set.new(JSON.parse(jwks_response))
end
end
Token Storage requires encryption. Database-stored tokens should use field-level encryption. Session cookies containing tokens need secure and httponly flags. Never store tokens in localStorage due to XSS vulnerability. sessionStorage provides slightly better security but remains vulnerable to XSS.
Practical Examples
Social Login Implementation demonstrates complete OAuth integration for user authentication. Applications redirect users to a social provider, receive authorization codes, exchange codes for tokens, and create user accounts.
# Complete social login implementation
class SocialAuthController < ApplicationController
skip_before_action :verify_authenticity_token, only: [:callback]
def initiate
provider_name = params[:provider]
provider = oauth_provider(provider_name)
state = SecureRandom.hex(16)
session[:oauth_state] = state
session[:oauth_provider] = provider_name
auth_url = provider.authorization_url(
callback_url,
'email profile',
state
)
redirect_to auth_url, allow_other_host: true
end
def callback
# Validate state
unless params[:state] == session[:oauth_state]
flash[:error] = 'Invalid state parameter'
return redirect_to login_path
end
# Handle errors
if params[:error]
flash[:error] = "Authentication failed: #{params[:error]}"
return redirect_to login_path
end
# Exchange code for token
provider = oauth_provider(session[:oauth_provider])
token = provider.exchange_code(params[:code], callback_url)
# Fetch user profile
user_data = fetch_user_profile(token, session[:oauth_provider])
# Find or create user
user = User.find_or_initialize_by(
provider: session[:oauth_provider],
provider_uid: user_data[:uid]
)
user.assign_attributes(
email: user_data[:email],
name: user_data[:name],
avatar_url: user_data[:avatar_url]
)
if user.save
# Store tokens
user.create_oauth_credential!(
provider: session[:oauth_provider],
access_token: token.token,
refresh_token: token.refresh_token,
expires_at: Time.at(token.expires_at)
)
session[:user_id] = user.id
redirect_to dashboard_path
else
flash[:error] = 'Failed to create user account'
redirect_to login_path
end
end
private
def oauth_provider(provider_name)
config = Rails.application.credentials.dig(:oauth, provider_name.to_sym)
OAuthService.new(config)
end
def fetch_user_profile(token, provider_name)
case provider_name
when 'google'
fetch_google_profile(token)
when 'github'
fetch_github_profile(token)
else
raise "Unknown provider: #{provider_name}"
end
end
def fetch_google_profile(token)
response = token.get('https://www.googleapis.com/oauth2/v2/userinfo')
data = JSON.parse(response.body)
{
uid: data['id'],
email: data['email'],
name: data['name'],
avatar_url: data['picture']
}
end
def fetch_github_profile(token)
response = token.get('https://api.github.com/user')
data = JSON.parse(response.body)
{
uid: data['id'].to_s,
email: data['email'],
name: data['name'] || data['login'],
avatar_url: data['avatar_url']
}
end
def callback_url
oauth_callback_url(protocol: 'https')
end
end
API Integration shows server-to-server OAuth using client credentials flow. Background jobs authenticate as service accounts to access external APIs.
# Service-to-service API integration
class ExternalAPIClient
def initialize
@client = OAuth2::Client.new(
ENV['API_CLIENT_ID'],
ENV['API_CLIENT_SECRET'],
site: 'https://api.example.com',
token_url: '/oauth/token'
)
@token = nil
end
def fetch_data(resource_path)
ensure_valid_token!
response = @token.get(resource_path)
JSON.parse(response.body)
rescue OAuth2::Error => e
if e.code == 'invalid_token'
# Token expired, refresh and retry
@token = nil
ensure_valid_token!
retry
else
raise
end
end
def create_resource(resource_path, data)
ensure_valid_token!
response = @token.post(resource_path) do |request|
request.headers['Content-Type'] = 'application/json'
request.body = data.to_json
end
JSON.parse(response.body)
end
private
def ensure_valid_token!
return if @token && !token_expired?
@token = @client.client_credentials.get_token(
scope: 'api:read api:write'
)
@token_expires_at = Time.now + @token.expires_in
end
def token_expired?
@token_expires_at && @token_expires_at < Time.now + 60
end
end
# Usage in background job
class DataSyncJob < ApplicationJob
def perform(account_id)
client = ExternalAPIClient.new
# Fetch data from external service
data = client.fetch_data("/accounts/#{account_id}/data")
# Process and store locally
Account.find(account_id).update!(
external_data: data,
last_synced_at: Time.current
)
end
end
Mobile App Authentication implements PKCE flow for native mobile applications without client secrets.
# PKCE flow for mobile app backend
class MobileAuthController < ApplicationController
skip_before_action :verify_authenticity_token
def initiate
# Mobile app provides code challenge
code_challenge = params[:code_challenge]
code_challenge_method = params[:code_challenge_method]
unless code_challenge && code_challenge_method == 'S256'
return render json: { error: 'Missing or invalid PKCE parameters' },
status: 400
end
# Store challenge for verification
auth_request = AuthorizationRequest.create!(
code_challenge: code_challenge,
state: SecureRandom.hex(16),
expires_at: 10.minutes.from_now
)
render json: {
authorization_url: authorization_url(auth_request),
state: auth_request.state
}
end
def exchange
code = params[:code]
code_verifier = params[:code_verifier]
state = params[:state]
# Find authorization request
auth_request = AuthorizationRequest.find_by(state: state)
unless auth_request && !auth_request.expired?
return render json: { error: 'Invalid or expired state' },
status: 400
end
# Verify code verifier matches challenge
computed_challenge = Base64.urlsafe_encode64(
Digest::SHA256.digest(code_verifier),
padding: false
)
unless computed_challenge == auth_request.code_challenge
return render json: { error: 'Invalid code verifier' },
status: 400
end
# Exchange code for tokens
client = oauth_client
token = client.auth_code.get_token(
code,
redirect_uri: mobile_callback_url,
code_verifier: code_verifier
)
# Create session for mobile user
user = find_or_create_user_from_token(token)
render json: {
access_token: token.token,
refresh_token: token.refresh_token,
expires_in: token.expires_in,
user: user.as_json(only: [:id, :email, :name])
}
end
private
def authorization_url(auth_request)
client = oauth_client
client.auth_code.authorize_url(
redirect_uri: mobile_callback_url,
state: auth_request.state,
code_challenge: auth_request.code_challenge,
code_challenge_method: 'S256',
scope: 'openid profile email'
)
end
end
Common Pitfalls
Insufficient Redirect URI Validation creates authorization code interception vulnerabilities. Applications accepting any redirect URI or performing substring matching allow attackers to register similar domains and capture codes. Always validate redirect URIs exactly against pre-registered values. Never use pattern matching or wildcards.
Missing State Parameter Validation enables CSRF attacks. Attackers craft authorization URLs without state parameters or with predictable state values. Victims clicking these links authorize the attacker's application. Applications must generate cryptographically random state values and verify them on callback. Never skip state validation even if the OAuth provider makes it optional.
# Common state validation mistakes
class AuthController < ApplicationController
# WRONG: No state generation
def authorize_bad
redirect_to client.auth_code.authorize_url(
redirect_uri: callback_url
# Missing state parameter
)
end
# WRONG: Predictable state
def authorize_bad_predictable
state = "user_#{current_user.id}" # Predictable
redirect_to client.auth_code.authorize_url(
redirect_uri: callback_url,
state: state
)
end
# WRONG: No state verification
def callback_bad
token = client.auth_code.get_token(params[:code])
# Should verify params[:state] matches session
end
# CORRECT: Random state with verification
def authorize_correct
state = SecureRandom.hex(32)
session[:oauth_state] = state
redirect_to client.auth_code.authorize_url(
redirect_uri: callback_url,
state: state
)
end
def callback_correct
unless params[:state] == session.delete(:oauth_state)
return render json: { error: 'Invalid state' }, status: 400
end
token = client.auth_code.get_token(params[:code])
end
end
Token Storage in Browser Storage exposes tokens to XSS attacks. LocalStorage and sessionStorage remain accessible to any JavaScript executing on the page. XSS vulnerabilities allow attackers to steal tokens. Store tokens in httponly, secure cookies or server-side sessions. Never expose tokens to client-side JavaScript.
Implicit Flow in SPAs creates token leakage risks. The implicit flow returns tokens in URL fragments visible in browser history, logs, and referrer headers. Authorization servers adopted PKCE with authorization code flow for single-page applications. Migrate applications from implicit flow to authorization code flow with PKCE.
Ignoring Token Expiration allows indefinite token usage. Applications that never check token expiration continue using expired tokens until the resource server rejects them. Check expires_at values and proactively refresh tokens before expiration. Handle refresh token expiration by redirecting to re-authentication.
Overly Broad Scopes violate the principle of least privilege. Requesting excessive permissions reduces user trust and increases damage from token compromise. Request only the scopes required for current operations. Applications can request additional scopes later when needed. Review scope requirements regularly.
# Scope management best practices
class APIController < ApplicationController
REQUIRED_SCOPES = {
profile: ['read:profile'],
posts: ['read:posts', 'write:posts'],
admin: ['read:users', 'write:users', 'delete:users']
}.freeze
before_action :verify_scope
def profile
# Requires read:profile scope
end
def create_post
# Requires write:posts scope
end
private
def verify_scope
required = REQUIRED_SCOPES[action_name.to_sym]
return true unless required
token_scopes = current_token.scopes
unless required.all? { |scope| token_scopes.include?(scope) }
render json: { error: 'Insufficient scope' }, status: 403
end
end
def current_token
# Extract and validate token from Authorization header
token_string = request.headers['Authorization']&.sub('Bearer ', '')
TokenValidator.validate(token_string)
end
end
Exposing Client Secrets in public clients compromises all users. Mobile apps and single-page applications cannot protect client secrets. Decompiling mobile apps or inspecting browser code reveals embedded secrets. Use PKCE for public clients instead of client secrets. Never embed secrets in client-side code.
Skipping ID Token Validation allows token substitution attacks. Applications accepting ID tokens without signature verification let attackers submit tokens issued to different clients. Applications skipping issuer validation accept tokens from malicious providers. Perform all validation steps: signature, issuer, audience, expiration, and nonce. Never trust tokens without full validation.
Insecure Token Transmission leaks tokens through network interception. Applications using HTTP instead of HTTPS expose tokens during transmission. Mixed content warnings indicate partial HTTPS implementation. Enforce HTTPS for all OAuth endpoints and API calls. Configure HSTS headers to prevent protocol downgrade attacks.
Refresh Token Misuse creates persistent access vulnerabilities. Applications storing refresh tokens client-side enable long-term token theft. Refresh tokens grant new access tokens without user interaction. Store refresh tokens server-side with encryption. Implement refresh token rotation where each refresh request returns a new refresh token and invalidates the old one.
Reference
OAuth 2.0 Grant Types
| Grant Type | Use Case | Client Type | User Involvement | Token Types |
|---|---|---|---|---|
| Authorization Code | Web applications with backend | Confidential | Yes | Access + Refresh |
| Authorization Code + PKCE | Mobile apps, SPAs | Public | Yes | Access + Refresh |
| Client Credentials | Service-to-service | Confidential | No | Access only |
| Device Code | Limited input devices | Public | Yes | Access + Refresh |
| Resource Owner Password | Legacy migration only | Confidential | Yes | Access + Refresh |
OpenID Connect Scopes
| Scope | Purpose | Claims Returned |
|---|---|---|
| openid | Required for OIDC | sub |
| profile | User profile data | name, family_name, given_name, picture, etc. |
| Email address | email, email_verified | |
| address | Physical address | address object |
| phone | Phone number | phone_number, phone_number_verified |
Standard ID Token Claims
| Claim | Type | Description | Required |
|---|---|---|---|
| iss | String | Issuer identifier | Yes |
| sub | String | Subject identifier | Yes |
| aud | String or Array | Audience (client ID) | Yes |
| exp | Number | Expiration time (Unix timestamp) | Yes |
| iat | Number | Issued at time | Yes |
| auth_time | Number | Authentication time | If requested |
| nonce | String | Request nonce | If sent in request |
| acr | String | Authentication context class reference | Optional |
| amr | Array | Authentication methods references | Optional |
| azp | String | Authorized party | If multiple audiences |
Token Response Fields
| Field | Description | Token Type |
|---|---|---|
| access_token | The access token string | All grants |
| token_type | Usually "Bearer" | All grants |
| expires_in | Seconds until expiration | All grants |
| refresh_token | Refresh token string | Some grants |
| scope | Granted scopes (space-separated) | All grants |
| id_token | JWT identity token | OIDC only |
Error Response Codes
| Error Code | Description | Typical Cause |
|---|---|---|
| invalid_request | Malformed request | Missing required parameter |
| invalid_client | Client authentication failed | Wrong client credentials |
| invalid_grant | Authorization grant invalid | Expired or used code |
| unauthorized_client | Client not authorized for grant type | Configuration mismatch |
| unsupported_grant_type | Grant type not supported | Requesting disabled grant |
| invalid_scope | Requested scope invalid | Unknown or unauthorized scope |
| access_denied | User denied authorization | User clicked "Deny" |
| temporarily_unavailable | Server temporarily unavailable | Retry later |
PKCE Parameters
| Parameter | Location | Description | Format |
|---|---|---|---|
| code_challenge | Authorization request | Hashed verifier | Base64URL(SHA256(verifier)) |
| code_challenge_method | Authorization request | Hash algorithm | S256 or plain |
| code_verifier | Token request | Original random string | 43-128 characters |
Common OAuth Endpoints
| Endpoint | Method | Purpose | Authentication |
|---|---|---|---|
| /oauth/authorize | GET | Start authorization flow | User login |
| /oauth/token | POST | Exchange code for tokens | Client credentials |
| /oauth/revoke | POST | Revoke token | Client credentials |
| /oauth/introspect | POST | Check token validity | Client credentials |
| /.well-known/openid-configuration | GET | Discovery document | None |
HTTP Headers
| Header | Value | Purpose |
|---|---|---|
| Authorization | Bearer ACCESS_TOKEN | Authenticate API requests |
| Content-Type | application/x-www-form-urlencoded | Token request format |
| Content-Type | application/json | API request/response format |
| Cache-Control | no-store | Prevent token caching |
| Pragma | no-cache | HTTP/1.0 cache prevention |
Security Checklist
| Item | Description | Priority |
|---|---|---|
| HTTPS only | All OAuth communications use TLS | Critical |
| Validate redirect URIs | Exact match against registered URIs | Critical |
| State parameter | Random state in all flows | Critical |
| PKCE for public clients | Use code challenge/verifier | Critical |
| Verify ID token signature | Validate JWT signature | Critical |
| Check token expiration | Validate exp claim | High |
| Validate issuer | Check iss claim | High |
| Validate audience | Check aud claim | High |
| Verify nonce | Match nonce in ID token | High |
| Short access token lifetime | 15-60 minutes recommended | Medium |
| Encrypt stored tokens | Field-level encryption | Medium |
| Implement token rotation | New refresh token per refresh | Medium |
| Scope minimization | Request minimum required scopes | Medium |
| Secure client secrets | Environment variables or vault | High |