두 개의 Rails 앱이 있다. 하나는 내부 직원용 앱(OTP 로그인, 특정 도메인 전용), 다른 하나는 심사/관리 시스템으로 Devise + JWT 기반이다. 내부 직원이 심사 시스템에도 접근해야 하는데, 계정을 따로 만들어 관리하기 싫었다.
“이미 내부 앱에 로그인돼 있으면, 심사 시스템에서 버튼 하나로 자동 로그인되면 안 되나?”
OAuth2를 붙이면 정석이지만, Doorkeeper 설정하고 scope 관리하고… 내부 서비스 두 개 사이에 그게 과할 수 있다. 더 단순한 방법을 택했다.
구조 선택: One-Time Token + HMAC
이미 두 서비스 사이에 webhook 연동이 있었다. ITSM 이벤트를 다른 서비스에 전달할 때 HMAC-SHA256으로 서명하는 패턴이 있었고, 이걸 SSO에도 그대로 쓰기로 했다.
[Service A - 로그인 버튼 클릭]
→ Service B /sso/authorize (로그인 여부 확인 + 토큰 발급)
→ Redirect → Service A /sso/callback?token=xxx&state=yyy
→ Service A가 Service B에 POST /sso/verify (HMAC 서명)
→ Service B가 유저 정보 반환
→ Service A Devise 세션 생성
핵심 보안 장치:
- Token: 일회용, 5분 만료, DB 저장 (
used_at체크) - HMAC-SHA256: Verify 요청이 신뢰된 서비스에서 온 것인지 검증
- state: CSRF 방지 (세션에 저장, callback에서 비교)
- redirect_uri 화이트리스트: 허용된 주소로만 리다이렉트
IdP 쪽 구현 (토큰 발급 서비스)
SsoToken 모델
class SsoToken < ApplicationRecord
belongs_to :user
scope :valid, -> { where(used_at: nil).where("expires_at > ?", Time.current) }
def use!
update!(used_at: Time.current)
end
end
마이그레이션:
create_table :sso_tokens do |t|
t.string :token, null: false, index: { unique: true }
t.references :user, null: false, foreign_key: true
t.string :redirect_uri, null: false
t.string :state, null: false
t.string :client_id, null: false
t.datetime :expires_at, null: false
t.datetime :used_at
t.timestamps
end
SSO 컨트롤러
class Auth::SsoController < ApplicationController
ALLOWED_REDIRECT_URIS = -> {
ENV.fetch("SSO_ALLOWED_REDIRECT_URIS", "").split(",").map(&:strip)
}
SSO_SHARED_SECRET = -> { ENV.fetch("SSO_SHARED_SECRET") }
before_action :require_authentication, only: [:authorize]
skip_before_action :verify_authenticity_token, only: [:verify]
# GET /auth/sso/authorize
def authorize
redirect_uri = params[:redirect_uri]
# redirect_uri 화이트리스트 검증
unless ALLOWED_REDIRECT_URIS.call.any? { |uri| redirect_uri.start_with?(uri) }
return render plain: "Invalid redirect_uri", status: :bad_request
end
sso_token = SsoToken.create!(
token: SecureRandom.urlsafe_base64(32),
user: current_user,
redirect_uri: redirect_uri,
state: params[:state],
client_id: params[:client_id],
expires_at: 5.minutes.from_now
)
redirect_to "#{redirect_uri}?token=#{sso_token.token}&state=#{params[:state]}",
allow_other_host: true
end
# POST /auth/sso/verify
def verify
request_body = request.body.read
signature = request.headers["X-Signature-SHA256"]
unless valid_signature?(request_body, signature)
return render json: { error: "Invalid signature" }, status: :unauthorized
end
token = JSON.parse(request_body)["token"]
sso_token = SsoToken.valid.find_by(token: token)
return render json: { error: "Invalid or expired token" }, status: :unauthorized unless sso_token
sso_token.use!
render json: { email: sso_token.user.email, name: sso_token.user.name }
end
private
def valid_signature?(body, signature)
return false unless signature.present?
expected = "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', SSO_SHARED_SECRET.call, body)}"
ActiveSupport::SecurityUtils.secure_compare(expected, signature)
end
end
포인트: require_authentication이 미로그인 유저를 로그인 페이지로 보낼 때, SSO authorize URL 전체(쿼리 파라미터 포함)를 session[:return_to]에 저장해야 한다. OTP 인증 후 redirect_back_or(dashboard_path)로 돌아오면 SSO 흐름이 이어진다.
SP 쪽 구현 (로그인 위임 서비스)
SSO Service
require "faraday"
require "openssl"
class SsoService
SSO_SHARED_SECRET = -> { ENV.fetch("SSO_SHARED_SECRET") }
def self.authorize_url(redirect_uri:, state:)
params = { client_id: "my_service", redirect_uri: redirect_uri, state: state }
"#{ENV.fetch('IDP_URL')}/auth/sso/authorize?#{params.to_query}"
end
def self.verify_token(token)
body = { token: token }.to_json
signature = "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', SSO_SHARED_SECRET.call, body)}"
response = Faraday.new(url: ENV.fetch('IDP_URL')).post("auth/sso/verify") do |req|
req.body = body
req.headers["Content-Type"] = "application/json"
req.headers["X-Signature-SHA256"] = signature
end
return nil unless response.success?
JSON.parse(response.body)
rescue Faraday::Error
nil
end
end
콜백 컨트롤러
class SsoController < ApplicationController
def initiate
state = SecureRandom.urlsafe_base64(32)
session[:sso_state] = state
redirect_to SsoService.authorize_url(
redirect_uri: sso_callback_url,
state: state
), allow_other_host: true
end
def callback
# CSRF 방지: state 검증
unless ActiveSupport::SecurityUtils.secure_compare(
params[:state], session.delete(:sso_state).to_s
)
redirect_to login_path, alert: "인증 실패 (state mismatch)"
return
end
user_data = SsoService.verify_token(params[:token])
return redirect_to login_path, alert: "인증 실패" unless user_data
# 유저 생성 또는 조회
user = User.find_or_initialize_by(email: user_data["email"])
if user.new_record?
user.assign_attributes(
name: user_data["name"],
role: :reviewer, # SSO 유저 기본 역할
password: SecureRandom.hex(16),
sso_provider: "internal"
)
user.save!
end
sign_in(user)
redirect_to root_path, notice: "로그인되었습니다."
end
end
Render 배포 시 삽질한 부분
autoDeploy: no 서비스에서 env var 업데이트가 구 커밋으로 배포됨
환경변수를 Render API/MCP로 업데이트하면 자동으로 재배포가 트리거된다. 그런데 autoDeploy: no인 서비스는 env var 업데이트 시점의 최신 커밋이 아니라 마지막으로 배포됐던 커밋으로 빌드한다.
새 코드를 push한 뒤 env var을 업데이트했는데, 막상 배포된 건 push 전 코드였다. 버튼이 안 보이는 이유가 여기 있었다.
해결: Render REST API로 수동 배포 트리거.
curl -X POST "https://api.render.com/v1/services/{SERVICE_ID}/deploys" \
-H "Authorization: Bearer $RENDER_API_KEY" \
-H "Content-Type: application/json" \
-d '{"clearCache": "do_not_clear"}'
응답에서 최신 커밋 메시지를 확인해 제대로 된 버전이 배포됐는지 검증할 수 있다.
프론트엔드: 로그인 버튼
Svelte + Inertia.js 환경에서 SSO 버튼은 Inertia router가 아닌 일반 <a> 태그를 써야 한다. Inertia는 내부 XHR 요청을 보내는데, SSO 흐름은 외부 서비스로 실제 페이지 이동(redirect)이 필요하기 때문이다.
<a
href="/sso/initiate"
class="w-full flex items-center justify-center gap-2 py-3 px-4
bg-[#1e3a5f] hover:bg-[#162d4a] text-white rounded-xl transition-all"
>
<svg><!-- shield icon --></svg>
내부 계정으로 로그인
<span class="text-xs text-white/70">직원 전용</span>
</a>
기존 이메일/비밀번호 폼 위에 또는 구분선과 함께 배치했다.
마무리
OAuth2가 표준이지만, 내부 서비스 두 개 사이라면 One-Time Token + HMAC 조합이 훨씬 가볍고 직관적이다. 이미 서비스 간 HMAC webhook이 있다면 동일 패턴을 SSO에 재사용할 수 있어서 코드 일관성도 좋다.
핵심 체크리스트:
- Token은 반드시 일회용 (
used_at) - 만료 시간 짧게 (5분 이하)
- HMAC 검증에
ActiveSupport::SecurityUtils.secure_compare사용 (타이밍 공격 방지) -
state파라미터로 CSRF 방지 - redirect_uri 화이트리스트 필수
- Render
autoDeploy: no서비스는 env var 업데이트 후 수동 배포 확인

💬 댓글
비밀번호를 기억해두면 나중에 내 댓글을 삭제할 수 있어요.