ruby

Advanced Web Application Security Patterns: Multi-Tenancy, OAuth2, ABAC and Beyond

Learn essential web application security patterns including multi-tenant auth, JWT tokens, OAuth2, MFA, and ABAC. Build secure, scalable authentication systems.

Advanced Web Application Security Patterns: Multi-Tenancy, OAuth2, ABAC and Beyond

Security in web applications is a journey, not a destination. I’ve found that once you move past the basic login form, the real work begins. In production, the questions become more complex. How do you isolate customers from each other? How do you give users very specific permissions? How do you handle login from a TV or a smart device? Let’s look at some patterns that help answer these questions.

Let’s start with a common need: serving multiple companies from one application. You might have acme.myapp.com and globex.myapp.com. Each is a separate tenant. The key is to bake this separation into the authentication process itself, right from the start.

Here’s one way to approach it. You create an authenticator that is aware of the tenant context. It looks at the incoming request, often the subdomain, to figure out which company the user is trying to access. It then ensures the user only exists within that company’s account.

class TenantAwareAuthentication
  def initialize(request)
    @request = request
    @account_domain = extract_from_subdomain(request)
    @account = Account.find_by(domain: @account_domain)
  end

  def authenticate(email, password)
    # The crucial part: find the user scoped to this account
    user = @account.users.find_by(email: email.downcase)
    return nil unless user

    # Standard password check
    return nil unless user.authenticate(password)
    return nil unless user.active?
    return nil unless @account.active?

    # Create a session that remembers both user and tenant
    create_session(user, @account)
  end

  private

  def extract_from_subdomain(request)
    host = request.host
    parts = host.split('.')
    # Skip common subdomains like 'www' or 'app'
    subdomain = parts.first
    subdomain unless ['www', 'app', 'api'].include?(subdomain)
  end

  def create_session(user, account)
    # Generate a strong, encrypted token
    crypt = ActiveSupport::MessageEncryptor.new(Rails.application.secrets.secret_key_base[0..31])
    session_token = crypt.encrypt_and_sign("#{Time.current.to_i}-#{SecureRandom.hex(16)}")

    session = UserSession.create!(
      user: user,
      account: account,
      ip_address: @request.remote_ip,
      session_token: session_token
    )

    # Set cookies that are tied to this account context
    cookies.encrypted[:session_token] = {
      value: session_token,
      expires: 1.week.from_now,
      domain: ".#{account.domain}",
      httponly: true,
      secure: Rails.env.production?
    }

    session
  end
end

The important idea here is scoping. @account.users.find_by(...) is a simple line with big implications. It guarantees a user from globex.myapp.com can never log into acme.myapp.com, even with the same email and password, because they simply don’t exist in that account’s list of users. The session token we create and store in the cookie is also encrypted and signed, making it tamper-proof.

Once a user is in the system, you need to control what they can do. Simple roles like “admin” or “user” often aren’t enough. You might need to say, “User A can edit these specific documents, but only on weekdays.” This is where a permission system with caching shines. Checking complex rules for every page load can slow your app down. We need to be smart about it.

I like to use a PermissionRegistry. It’s a single object you ask, “Can this user perform this action on this thing?” It handles the complex logic and remembers the answer for a short time so it doesn’t have to do the hard work repeatedly.

class PermissionRegistry
  def initialize(user, resource)
    @user = user
    @resource = resource
    @cache_key = "perms:#{user.id}:#{resource.class}:#{resource.id}"
  end

  def can?(action)
    # First, check our fast cache
    cached_result = Rails.cache.read(@cache_key)
    return cached_result[action] if cached_result&.key?(action)

    # If not in cache, calculate all permissions for this user/resource pair
    permissions = calculate_permissions

    # Store the whole set in cache for 5 minutes
    Rails.cache.write(@cache_key, permissions, expires_in: 5.minutes)

    # Now return the answer for the specific action
    permissions[action]
  end

  private

  def calculate_permissions
    permissions = {}

    # 1. Permissions from the user's roles
    @user.roles.each do |role|
      role.permissions.each do |permission|
        if applies_to_resource?(permission)
          permissions[permission.action] = true
        end
      end
    end

    # 2. Any specific permissions granted directly to this user for this resource
    user_specific = @user.user_permissions.where(resource: @resource)
    user_specific.each { |p| permissions[p.action] = true }

    permissions
  end

  def applies_to_resource?(permission)
    case permission.scope
    when 'global'
      true # Applies to everything
    when 'resource_type'
      @resource.is_a?(permission.resource_type.constantize)
    when 'instance'
      permission.resource_id == @resource.id
    end
  end
end

# Using it in a controller feels clean
class DocumentsController < ApplicationController
  before_action :set_document

  def update
    registry = PermissionRegistry.new(current_user, @document)

    if registry.can?(:update)
      @document.update!(document_params)
      render json: @document
    else
      head :forbidden # Simple and clear
    end
  end

  private

  def set_document
    @document = Document.find(params[:id])
  end
end

The beauty of this pattern is its efficiency. For a busy page, the first load might calculate permissions. The next load within five minutes will simply read the result from the fast cache (like Redis). It keeps your authorization logic centralized in one class, making it easier to test and change.

For modern applications, especially APIs, cookie-based sessions aren’t always the answer. Mobile apps or single-page applications often use JSON Web Tokens (JWT). A JWT is a self-contained packet of information that is cryptographically signed. The server doesn’t need to store it; it just needs to verify the signature.

But JWTs come with a caveat: they are hard to invalidate before they expire. A common pattern to solve this is using short-lived access tokens paired with longer-lived refresh tokens.

class JwtAuthService
  SECRET_KEY = Rails.application.secrets.secret_key_base

  def generate_token_pair(user)
    access_token = generate_access_token(user)
    refresh_token = generate_refresh_token(user)

    {
      access_token: access_token,
      refresh_token: refresh_token,
      token_type: 'Bearer',
      expires_in: 15.minutes.to_i
    }
  end

  private

  def generate_access_token(user)
    payload = {
      sub: user.id,
      email: user.email,
      iat: Time.current.to_i,
      exp: 15.minutes.from_now.to_i, # Short life!
      jti: SecureRandom.uuid # A unique ID for this token
    }
    JWT.encode(payload, SECRET_KEY, 'HS256')
  end

  def generate_refresh_token(user)
    # This is stored in the database so we can revoke it
    token = SecureRandom.hex(32)
    RefreshToken.create!(
      user: user,
      token: token,
      expires_at: 30.days.from_now,
      ip_address: Current.request&.remote_ip
    )
    token
  end

  def authenticate_token(token_string)
    # Decode and verify the JWT signature
    payload = JWT.decode(token_string, SECRET_KEY, true, algorithm: 'HS256').first
    user = User.find(payload['sub'])

    # Critical: Check if this specific token was revoked using its 'jti'
    return nil if TokenRevocation.exists?(jti: payload['jti'])

    user
  rescue JWT::ExpiredSignature, JWT::DecodeError
    nil
  end

  def refresh_tokens(refresh_token_string)
    # Find the stored refresh token
    stored_token = RefreshToken.find_by(token: refresh_token_string)
    return nil unless stored_token
    return nil if stored_token.expired? || stored_token.revoked?

    user = stored_token.user

    # Revoke the old refresh token (one-time use)
    stored_token.revoke!

    # Issue a brand new pair
    generate_token_pair(user)
  end
end

This pattern gives us the best of both worlds. The access token is stateless and fast to validate, but it expires quickly (15 minutes). If it’s stolen, the window of misuse is small. The refresh token is long-lived but stored securely in the database. If a user logs out, we can delete that refresh token record, preventing the generation of any new access tokens. The jti (JWT ID) in the access token lets us blacklist individual tokens if we need to revoke them before they expire.

Passwords alone are increasingly insecure. Adding a second factor, like a code from an app on your phone, dramatically increases security. This is Multi-Factor Authentication (MFA). A popular standard for this is Time-based One-Time Passwords (TOTP), which is what apps like Google Authenticator use.

The process has two main parts: setup and verification. During setup, we give the user a secret key to put into their app. The app and our server use this secret, combined with the current time, to generate the same six-digit code.

class MfaService
  def initialize(user)
    @user = user
  end

  def setup
    # Generate a random secret (this is what goes into the authenticator app)
    secret = ROTP::Base32.random

    # Create backup codes in case the user loses their phone
    backup_codes = generate_backup_codes

    # Store the secret ENCRYPTED. Never store it in plain text.
    encrypted_secret = encrypt(secret)

    @user.update!(
      mfa_secret: encrypted_secret,
      mfa_backup_codes: backup_codes.map { |code| digest_code(code) },
      mfa_enabled: false # Wait until they verify a code first
    )

    # Provide the data needed to set up the app
    {
      secret: secret, # Show this to the user
      provisioning_uri: ROTP::TOTP.new(secret).provisioning_uri(@user.email),
      backup_codes: backup_codes # Show these once, securely
    }
  end

  def verify_and_enable(entered_code)
    # Get the secret back from the encrypted storage
    secret = decrypt(@user.mfa_secret)
    totp = ROTP::TOTP.new(secret)

    # Verify the code, allowing a little clock drift
    if totp.verify(entered_code, drift_behind: 15, drift_ahead: 15)
      @user.update!(mfa_enabled: true)
      true
    elsif verify_backup_code(entered_code)
      # Allow a backup code to be used in setup too
      @user.update!(mfa_enabled: true)
      true
    else
      false
    end
  end

  def verify_login(entered_code)
    return false unless @user.mfa_enabled?

    secret = decrypt(@user.mfa_secret)
    totp = ROTP::TOTP.new(secret)

    if totp.verify(entered_code, drift_behind: 15, drift_ahead: 15)
      true
    else
      verify_backup_code(entered_code) # Try if it's a backup code
    end
  end

  private

  def generate_backup_codes
    10.times.map { SecureRandom.hex(4).upcase.insert(4, '-') } # Format: ABCD-EF12
  end

  def verify_backup_code(code)
    hashed_code = digest_code(code)
    index = @user.mfa_backup_codes.index(hashed_code)

    if index
      # Remove the used backup code
      @user.mfa_backup_codes.delete_at(index)
      @user.save!
      true
    else
      false
    end
  end

  def encrypt(plaintext)
    crypt = ActiveSupport::MessageEncryptor.new(Rails.application.secrets.secret_key_base[0..31])
    crypt.encrypt_and_sign(plaintext)
  end
end

The most critical security step here is encrypting the secret before storing it. If someone gets a copy of your database, they should not be able to steal these secrets. The backup codes are also hashed (using something like BCrypt) when stored, just like passwords. This pattern lets users secure their account with a second factor while giving them a safe recovery option.

Sometimes, you want to let users log into your app using their account from another service, like Google or GitHub. Other times, your app might be the service others want to connect to. Implementing an OAuth2 provider lets other applications get limited access to your users’ data with their permission.

The OAuth2 “Authorization Code” flow is the most common and secure. It involves a few redirects between your app, the third-party app, and the user.

class OAuthProviderService
  def authorize(client_id, redirect_uri, user, approve)
    client = OAuthClient.find_by(client_id: client_id)
    return error_redirect unless client
    return error_redirect unless client.redirect_uris.include?(redirect_uri)

    if approve
      # User said "Yes, allow this app"
      code = generate_authorization_code(user, client, redirect_uri)

      # Redirect back to the app with the one-time code
      uri = URI.parse(redirect_uri)
      uri.query = URI.encode_www_form(code: code)
      { redirect_to: uri.to_s }
    else
      # User said "No"
      uri = URI.parse(redirect_uri)
      uri.query = URI.encode_www_form(error: 'access_denied')
      { redirect_to: uri.to_s }
    end
  end

  def token(code)
    # Exchange the one-time code for tokens
    auth_code = AuthorizationCode.find_by(code: code)
    return invalid_grant_error if auth_code.nil? || auth_code.expired?

    user = auth_code.user
    client = auth_code.client

    # Create the real access tokens
    access_token = generate_access_token(user, client)
    refresh_token = generate_refresh_token(user, client)

    # Destroy the authorization code so it can't be used again
    auth_code.destroy!

    {
      access_token: access_token,
      refresh_token: refresh_token,
      token_type: 'Bearer',
      expires_in: 1.hour.to_i
    }
  end

  private

  def generate_authorization_code(user, client, redirect_uri)
    code = SecureRandom.urlsafe_base64(32)
    AuthorizationCode.create!(
      user: user,
      client: client,
      code: code,
      redirect_uri: redirect_uri,
      expires_at: 10.minutes.from_now
    )
    code
  end

  def generate_access_token(user, client)
    token = SecureRandom.hex(32)
    AccessToken.create!(
      user: user,
      client: client,
      token: token,
      expires_at: 1.hour.from_now
    )
    token
  end
end

The key player here is the authorization_code. It’s a temporary, one-time password that the third-party app gets after the user says “yes.” The app then exchanges this code in a secure, server-to-server call for the real access_token. This extra step prevents the token from being exposed to the user’s browser, which is more secure.

What about logging into a game console or a smart TV? Typing a username and password with a remote control is a nightmare. The OAuth2 “Device Authorization Grant” is made for this. It lets the user authorize the device by going to a website on their computer or phone.

The device asks your server, “Hey, I want to log in. Give me a code for the user.” Your server gives it a device_code and a user_code. The device shows the user_code (e.g., “ABCD-EFGH”) and a website URL to the user. The user goes to that URL on their other device, logs in, and types in the user_code. Your server then pairs that user_code with the user’s account. Meanwhile, the device is politely asking every few seconds, “Has the user authorized me yet?”

class DeviceAuthService
  def create_device_flow(client_id)
    device_code = SecureRandom.hex(32)
    user_code = generate_human_friendly_code # e.g., "WDJB-MXHT"

    request = DeviceFlowRequest.create!(
      device_code: device_code,
      user_code: user_code,
      client_id: client_id,
      expires_at: 10.minutes.from_now,
      status: 'pending'
    )

    {
      device_code: device_code,
      user_code: user_code,
      verification_uri: "https://myapp.com/device",
      verification_uri_complete: "https://myapp.com/device?code=#{user_code}",
      expires_in: 600,
      interval: 5 # Tell the device to check every 5 seconds
    }
  end

  def poll_for_authorization(device_code)
    request = DeviceFlowRequest.find_by(device_code: device_code)
    return { error: 'expired' } if request.nil? || request.expired?

    if request.status == 'approved'
      user = request.user
      # Generate tokens for the device to use
      tokens = JwtAuthService.new.generate_token_pair(user)
      request.destroy! # Clean up
      tokens
    else
      { error: 'authorization_pending' } # Keep waiting
    end
  end

  def approve_user_code(user_code, current_user)
    request = DeviceFlowRequest.find_by(user_code: user_code, status: 'pending')
    return false unless request

    request.update!(user: current_user, status: 'approved')
    true
  end

  private

  def generate_human_friendly_code
    charset = ('A'..'Z').to_a - ['I', 'O'] # Avoid ambiguous letters
    8.times.map { charset.sample }.join.scan(/.{4}/).join('-')
  end
end

This pattern completely removes the need for input on the limited device. The user does the heavy lifting of authentication on a proper device, and the TV or speaker just gets a token when it’s ready. The interval parameter tells the device not to bombard your server with requests, keeping things efficient.

Knowing who did what and when is crucial for security and debugging. If something goes wrong, you need a clear trail. An audit log records every important security event: logins, logouts, password changes, permission denials.

But it’s not just about recording. You can use this log data in real-time to spot suspicious behavior. Is someone trying to log in from two different countries at the same time? Did an account just fail to log in 20 times in a row?

class SecurityLogger
  def self.log_event(event_type, user: nil, metadata: {})
    event = SecurityEvent.create!(
      event_type: event_type,
      user: user,
      ip_address: Current.request&.remote_ip,
      user_agent: Current.request&.user_agent,
      metadata: metadata
    )

    # Optional: Send to a security monitoring system
    send_to_siem_system(event) if ENV['SIEM_ENDPOINT']

    event
  end

  def self.analyze_login_attempt(user, request)
    detector = LoginAnomalyDetector.new(user, request)

    if detector.too_many_recent_failures?
      SecurityLogger.log_event(:login_failure_flood, user: user, metadata: { action: 'blocked' })
      return { allow: false, reason: 'rate_limited' }
    end

    if detector.unusual_location?
      SecurityLogger.log_event(:login_unusual_location, user: user, metadata: { ip: request.remote_ip })
      return { allow: true, require_mfa: true } # Allow, but force MFA
    end

    { allow: true }
  end
end

# Using it in your login logic
def create_session
  user = User.find_by(email: params[:email])

  if user&.authenticate(params[:password])
    # Check for anomalies before allowing login
    analysis = SecurityLogger.analyze_login_attempt(user, request)

    if analysis[:allow]
      if analysis[:require_mfa] || user.mfa_enabled?
        # Proceed to MFA step
        session[:mfa_user_id] = user.id
      else
        # Log the successful login
        SecurityLogger.log_event(:login_success, user: user)
        create_user_session(user)
      end
    else
      SecurityLogger.log_event(:login_blocked, user: user, metadata: { reason: analysis[:reason] })
      render json: { error: 'Login temporarily blocked' }, status: :too_many_requests
    end
  else
    # Log the failure
    SecurityLogger.log_event(:login_failure, metadata: { email: params[:email] })
    render json: { error: 'Invalid credentials' }, status: :unauthorized
  end
end

Audit logs turn your application from a black box into a transparent system. They help you find problems, meet compliance rules, and actively defend against attacks by spotting strange patterns as they happen.

Finally, let’s talk about a very flexible way to control access: Attribute-Based Access Control (ABAC). Instead of just saying “admins can edit documents,” ABAC lets you write rules like “Users in the Finance department can approve invoices, but only if the invoice amount is under $10,000 and it’s a weekday.”

You define rules that check many attributes of the user, the resource, and the environment.

class AbacPolicyEngine
  def initialize(user, resource, action, environment = {})
    @user = user
    @resource = resource
    @action = action
    @environment = environment # e.g., { time_of_day: Time.current, ip: '192.168.1.1' }
  end

  def evaluate
    # Load rules that might apply to this situation
    rules = load_relevant_rules

    # Check if any rule grants permission
    rules.any? { |rule| rule_matches?(rule) }
  end

  private

  def rule_matches?(rule)
    # Check user conditions (e.g., user.role == 'manager')
    return false unless user_matches?(rule[:user_conditions])

    # Check resource conditions (e.g., document.status == 'draft')
    return false unless resource_matches?(rule[:resource_conditions])

    # Check environmental conditions (e.g., Time.current.hour.between?(9,17))
    return false unless environment_matches?(rule[:environment_conditions])

    true
  end

  def user_matches?(conditions)
    conditions.all? do |condition|
      # Example condition: { attribute: 'department', operator: 'equals', value: 'Sales' }
      actual_value = @user.public_send(condition[:attribute])
      compare(actual_value, condition[:operator], condition[:value])
    end
  end

  def environment_matches?(conditions)
    conditions.all? do |condition|
      actual_value = @environment[condition[:attribute]]
      compare(actual_value, condition[:operator], condition[:value])
    end
  end

  def compare(actual, operator, expected)
    case operator
    when 'equals'
      actual == expected
    when 'greater_than'
      actual > expected
    when 'in'
      expected.include?(actual)
    # ... other operators
    end
  end
end

# A rule defined in JSON or a database record
approval_rule = {
  action: 'approve',
  user_conditions: [
    { attribute: 'department', operator: 'equals', value: 'Finance' },
    { attribute: 'role', operator: 'in', value: ['Manager', 'Director'] }
  ],
  resource_conditions: [
    { attribute: 'type', operator: 'equals', value: 'Invoice' },
    { attribute: 'amount_cents', operator: 'less_than', value: 10_000_00 }
  ],
  environment_conditions: [
    { attribute: 'time_of_day', operator: 'in', value: { start: '09:00', end: '17:00' } }
  ]
}

# Using it is straightforward
def approve_invoice
  invoice = Invoice.find(params[:id])
  policy = AbacPolicyEngine.new(
    current_user,
    invoice,
    'approve',
    { time_of_day: Time.current, ip: request.remote_ip }
  )

  if policy.evaluate
    invoice.approve!
    render json: { status: 'approved' }
  else
    render json: { error: 'Not authorized for this action' }, status: :forbidden
  end
end

ABAC is incredibly powerful for complex business rules. It moves authorization logic out of your code and into data (rules), which can often be managed by non-developers. The downside is it can be more complex to implement and slower to evaluate than simpler role-based checks, so caching the results of these policy evaluations is often essential.

Each of these patterns solves a different, real-world security challenge. Multi-tenancy keeps data separate. Permission registries and ABAC give fine-grained control. JWT and OAuth handle modern API and third-party access. Device flows accommodate unusual hardware. MFA adds a critical layer of defense. Audit logs give you visibility.

None of them are silver bullets, and implementing them adds complexity. You must always weigh the security benefit against the development and maintenance cost. However, for applications handling sensitive data or serving many users, these patterns provide the robust, scalable security foundations you need to build with confidence. Start simple, but know that these tools are here for when your needs grow.

Keywords: web application security, multi-tenant authentication, permission management system, JWT authentication, OAuth2 implementation, multi-factor authentication, TOTP implementation, device authorization grant, audit logging security, ABAC access control, tenant-aware authentication, session management security, permission caching, refresh token security, backup codes MFA, authorization code flow, device flow OAuth, security event logging, attribute-based access control, encryption session tokens, user permission scoping, secure token generation, MFA secret encryption, OAuth client validation, login anomaly detection, security monitoring, role-based permissions, API authentication, mobile app authentication, single sign-on, token revocation, password security, authentication patterns, authorization best practices, security compliance logging, tenant isolation, user session security, access token validation, security architecture patterns, web security implementation, authentication middleware, permission validation, secure cookie handling, database security scoping, cryptographic token signing, security policy engine, access control rules, authentication flow security, modern authentication methods



Similar Posts
Blog Image
8 Advanced Ruby on Rails Techniques for Building a High-Performance Job Board

Discover 8 advanced techniques to elevate your Ruby on Rails job board. Learn about ElasticSearch, geolocation, ATS, real-time updates, and more. Optimize your platform for efficiency and user engagement.

Blog Image
How Can Method Hooks Transform Your Ruby Code?

Rubies in the Rough: Unveiling the Magic of Method Hooks

Blog Image
7 Proven Ruby Memory Optimization Techniques for High-Performance Applications

Learn effective Ruby memory management techniques in this guide. Discover how to profile, optimize, and prevent memory leaks using tools like ObjectSpace and custom trackers to keep your applications performant and stable. #RubyOptimization

Blog Image
Is Draper the Magic Bean for Clean Rails Code?

Décor Meets Code: Discover How Draper Transforms Ruby on Rails Presentation Logic

Blog Image
6 Essential Ruby on Rails Database Optimization Techniques for Faster Queries

Optimize Rails database performance with 6 key techniques. Learn strategic indexing, query optimization, and eager loading to build faster, more scalable web applications. Improve your Rails skills now!

Blog Image
Unlocking Rust's Hidden Power: Emulating Higher-Kinded Types for Flexible Code

Rust doesn't natively support higher-kinded types, but they can be emulated using traits and associated types. This allows for powerful abstractions like Functors and Monads. These techniques enable writing generic, reusable code that works with various container types. While complex, this approach can greatly improve code flexibility and maintainability in large systems.