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.