we-promise/sure · error · EnableBankingError

unauthorized

unauthorized

Error message

Invalid credentials or expired JWT

What it means

Raised by Provider::EnableBanking#handle_response on HTTP 401: the RS256 JWT the client generates (kid = application_id, signed with the configured private key, exp = now + 3600) was rejected as invalid or expired. Because a fresh JWT is signed per request, 'expired' in practice means server-visible clock skew or a mismatched application_id/private_key pair, not an old token being reused.

Source

Thrown at app/models/provider/enable_banking.rb:287

    def auth_headers
      {
        "Authorization" => "Bearer #{generate_jwt}",
        "Accept" => "application/json"
      }
    end

    def handle_response(response)
      case response.code
      when 200, 201
        parse_response_body(response)
      when 204
        {}
      when 400
        response_data = parse_error_response_body(response)
        raise EnableBankingError.new("Bad request to Enable Banking API: #{response.body}", :bad_request, response_data: response_data)
      when 401
        raise EnableBankingError.new("Invalid credentials or expired JWT", :unauthorized)
      when 403
        raise EnableBankingError.new("Access forbidden - check your application permissions", :access_forbidden)
      when 404
        raise EnableBankingError.new("Resource not found", :not_found)
      when 408
        raise EnableBankingError.new("Request timeout from Enable Banking API", :timeout)
      when 422
        response_data = parse_response_body(response)
        raise EnableBankingError.new("Validation error from Enable Banking API: #{response.body}", :validation_error, response_data: response_data)
      when 429
        raise EnableBankingError.new("Rate limit exceeded. Please try again later.", :rate_limited)
      else
        response_data = parse_error_response_body(response)
        raise EnableBankingError.new("Failed to fetch data: #{response.code} #{response.message} - #{response.body}", :fetch_failed, response_data: response_data)
      end
    end

    def parse_error_response_body(response)

View on GitHub (pinned to e69894adb9)

Solutions

  1. Confirm application_id and private key come from the SAME Enable Banking application (kid must match the key the portal knows)
  2. Re-download the current private key PEM from the portal and update the stored secret, since rotated keys may be revoked immediately
  3. Check clock sync (timedatectl status / ntp) — drift beyond the JWT validity window (1 hour) produces exactly this error
  4. Make a canary get_aspsps(country: 'FI') call at boot to fail fast on credential problems before user-facing flows
Defensive patterns

Strategy: validation

Validate before calling

def enable_banking_credentials_ok?(application_id, key_pem)
  key = OpenSSL::PKey.read(key_pem)
  return false unless key.is_a?(OpenSSL::PKey::RSA)
  # canary: cheapest authenticated call proves kid (application_id) matches the key
  probe = Provider::EnableBanking.new(application_id: application_id, client_certificate: key_pem)
  probe.get_aspsps(country: "FI") && true
rescue OpenSSL::PKey::PKeyError, Provider::EnableBanking::EnableBankingError => e
  e.error_type != :unauthorized
end

Type guard

def eb_unauthorized?(error)
  error.is_a?(Provider::EnableBanking::EnableBankingError) && error.error_type == :unauthorized
end

Try / catch

begin
  client.get_aspsps(country: "DE")
rescue Provider::EnableBanking::EnableBankingError => e
  raise unless e.error_type == :unauthorized
  # JWT is freshly signed per request: this means wrong app_id/key pair or clock skew — fix config, don't retry
  connection.update!(status: "credential_error")
  raise
end

Prevention

When it happens

Trigger: application_id from one Enable Banking application paired with another application's private key; the private key was rotated in the portal and the stored copy revoked; system clock on the app server drifting minutes ahead so exp appears in the past (or iat in the future) to Enable Banking.

Common situations: Key rotation without updating the stored secret, copying credentials between staging/production applications, VMs without NTP drifting clock, typos in the application UUID.

Understand the failure class

Related errors


AI-assisted analysis of we-promise/sure@e69894adb9 (2026-08-21). Data as JSON: /api/errors/d634b7fe462bba7d. Report an issue: GitHub.